From c62e7f1ba506ade0897ff58eaedb1ba95f5c5783 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 13 Sep 2026 04:14:29 +0900 Subject: [PATCH 1/3] websem: repair ordinary-opacity source precision Carry complete isolated source domains across rframe, prepare active blur sources once, and quarantine unrepresented composition profiles. Reopen both opacity checklist rows; retain the radial element own-row precedent. Add 36 immutable Chromium cells, 17 named refusal witnesses and actual-CLI assertions. Independent manual TICK/LAW and REPRO pass; no Workflow runner was exposed. --- .github/workflows/consolidation-gates.yml | 5 +- crates/n0/README.md | 14 +- crates/n0/src/drawlist.rs | 1 + crates/n0/src/glyphless.rs | 146 +++++++- crates/n0/src/paint.rs | 116 +++++- crates/n0/tests/group_blending.rs | 18 +- crates/n0/tests/opacity_source_domain.rs | 280 +++++++++++++++ crates/n0_cli/README.md | 33 +- crates/rframe/README.md | 36 +- crates/rframe/src/lib.rs | 5 +- crates/rframe/src/scope.rs | 137 +++++-- crates/rframe/tests/blend_contract.rs | 12 +- crates/rframe/tests/mask_contract.rs | 7 +- crates/rframe/tests/opacity_source_domain.rs | 337 ++++++++++++++++++ crates/rframe/tests/scope_stream.rs | 7 +- crates/websem/src/svg.rs | 324 +++++++++++++++-- crates/websem/tests/opacity_contract.rs | 2 +- crates/websem/tests/opacity_source_domain.rs | 206 +++++++++++ crates/websem/tests/paint_order_contract.rs | 2 +- crates/websem/tests/unsupported_corpus.rs | 85 +++++ docs/wg/consolidation/svg-engine-of-record.md | 138 ++++++- docs/wg/consolidation/web-checklist.md | 19 +- fixtures/web-first/README.md | 14 +- fixtures/web-first/STATUS.md | 57 ++- .../html-opacity-source-host-high.png | Bin 0 -> 4561 bytes .../chromium/html-opacity-source-host-low.png | Bin 0 -> 4597 bytes .../html-opacity-source-root-high.png | Bin 0 -> 4561 bytes .../chromium/html-opacity-source-root-low.png | Bin 0 -> 4597 bytes .../svg-opacity-source-blur-absent.png | Bin 0 -> 3340 bytes .../chromium/svg-opacity-source-blur-arc.png | Bin 0 -> 3587 bytes .../chromium/svg-opacity-source-blur-high.png | Bin 0 -> 3594 bytes .../chromium/svg-opacity-source-blur-low.png | Bin 0 -> 3573 bytes .../chromium/svg-opacity-source-blur-srgb.png | Bin 0 -> 3220 bytes .../chromium/svg-opacity-source-blur-zero.png | Bin 0 -> 941 bytes ...opacity-source-disabled-ellipse-height.png | Bin 0 -> 3103 bytes ...-opacity-source-disabled-ellipse-width.png | Bin 0 -> 3103 bytes ...vg-opacity-source-disabled-rect-height.png | Bin 0 -> 3103 bytes ...svg-opacity-source-disabled-rect-width.png | Bin 0 -> 3103 bytes .../svg-opacity-source-disabled-rect.png | Bin 0 -> 3103 bytes .../svg-opacity-source-enclosing-clip.png | Bin 0 -> 2626 bytes .../svg-opacity-source-leaf-stroke.png | Bin 0 -> 3696 bytes .../svg-opacity-source-linear-simple.png | Bin 0 -> 1555 bytes .../svg-opacity-source-pattern-absent.png | Bin 0 -> 1094 bytes .../svg-opacity-source-pattern-fractional.png | Bin 0 -> 1132 bytes .../svg-opacity-source-pattern-high.png | Bin 0 -> 1119 bytes .../svg-opacity-source-pattern-low.png | Bin 0 -> 1118 bytes .../svg-opacity-source-pattern-mirror.png | Bin 0 -> 1101 bytes .../svg-opacity-source-pattern-stroke.png | Bin 0 -> 1828 bytes .../svg-opacity-source-pattern-translated.png | Bin 0 -> 1177 bytes .../svg-opacity-source-radial-absent.png | Bin 0 -> 3092 bytes .../svg-opacity-source-radial-fractional.png | Bin 0 -> 3092 bytes .../svg-opacity-source-radial-high.png | Bin 0 -> 3395 bytes .../svg-opacity-source-radial-low.png | Bin 0 -> 3387 bytes .../svg-opacity-source-radial-mirror.png | Bin 0 -> 3063 bytes .../svg-opacity-source-radial-simple.png | Bin 0 -> 3103 bytes .../svg-opacity-source-radial-stroke.png | Bin 0 -> 4022 bytes .../svg-opacity-source-radial-translated.png | Bin 0 -> 3113 bytes .../svg-opacity-source-radial-uniform.png | Bin 0 -> 2105 bytes .../svg-opacity-source-root-transparent.png | Bin 0 -> 4561 bytes .../chromium/svg-opacity-source-use.png | Bin 0 -> 3103 bytes .../html-opacity-source-host-high.html | 1 + .../html-opacity-source-host-low.html | 1 + .../html-opacity-source-root-high.html | 1 + .../html-opacity-source-root-low.html | 1 + fixtures/web-first/justfile | 4 + fixtures/web-first/oracle-bake.json | 326 ++++++++++++++++- fixtures/web-first/primitives.json | 288 +++++++++++++++ .../svg-opacity-source-blur-absent.svg | 1 + .../web-first/svg-opacity-source-blur-arc.svg | 1 + .../svg-opacity-source-blur-high.svg | 1 + .../web-first/svg-opacity-source-blur-low.svg | 1 + .../svg-opacity-source-blur-srgb.svg | 1 + .../svg-opacity-source-blur-zero.svg | 1 + ...opacity-source-disabled-ellipse-height.svg | 1 + ...-opacity-source-disabled-ellipse-width.svg | 1 + ...vg-opacity-source-disabled-rect-height.svg | 1 + ...svg-opacity-source-disabled-rect-width.svg | 1 + .../svg-opacity-source-disabled-rect.svg | 1 + .../svg-opacity-source-enclosing-clip.svg | 1 + .../svg-opacity-source-leaf-stroke.svg | 1 + .../svg-opacity-source-linear-simple.svg | 1 + .../svg-opacity-source-pattern-absent.svg | 1 + .../svg-opacity-source-pattern-fractional.svg | 1 + .../svg-opacity-source-pattern-high.svg | 1 + .../svg-opacity-source-pattern-low.svg | 1 + .../svg-opacity-source-pattern-mirror.svg | 1 + .../svg-opacity-source-pattern-stroke.svg | 1 + .../svg-opacity-source-pattern-translated.svg | 1 + .../svg-opacity-source-radial-absent.svg | 1 + .../svg-opacity-source-radial-fractional.svg | 1 + .../svg-opacity-source-radial-high.svg | 1 + .../svg-opacity-source-radial-low.svg | 1 + .../svg-opacity-source-radial-mirror.svg | 1 + .../svg-opacity-source-radial-simple.svg | 1 + .../svg-opacity-source-radial-stroke.svg | 1 + .../svg-opacity-source-radial-translated.svg | 1 + .../svg-opacity-source-radial-uniform.svg | 1 + .../svg-opacity-source-root-transparent.svg | 1 + fixtures/web-first/svg-opacity-source-use.svg | 1 + fixtures/web-first/unsupported/README.md | 8 +- .../svg-opacity-source-child-transform.svg | 1 + ...svg-opacity-source-clipped-contributor.svg | 1 + .../svg-opacity-source-complex-stroke.svg | 1 + .../svg-opacity-source-enclosing-filter.svg | 1 + .../svg-opacity-source-enclosing-mask.svg | 1 + .../svg-opacity-source-marker-client.svg | 1 + .../svg-opacity-source-mask-program.svg | 1 + .../svg-opacity-source-nested-contributor.svg | 1 + .../svg-opacity-source-omitted-stroke.svg | 1 + .../unsupported/svg-opacity-source-path.svg | 1 + .../svg-opacity-source-pattern-program.svg | 1 + .../svg-opacity-source-root-path.svg | 1 + .../unsupported/svg-opacity-source-shear.svg | 1 + ...opacity-source-transparent-contributor.svg | 1 + .../svg-opacity-source-unequal-scale.svg | 1 + .../svg-paint-server-source-filter.svg | 1 + .../svg-paint-server-source-mask.svg | 1 + .../grida-reftest/svg-assertions/README.md | 18 + .../svg-assertions/opacity-source.json | 242 +++++++++++++ 119 files changed, 2827 insertions(+), 113 deletions(-) create mode 100644 crates/n0/tests/opacity_source_domain.rs create mode 100644 crates/rframe/tests/opacity_source_domain.rs create mode 100644 crates/websem/tests/opacity_source_domain.rs create mode 100644 fixtures/web-first/chromium/html-opacity-source-host-high.png create mode 100644 fixtures/web-first/chromium/html-opacity-source-host-low.png create mode 100644 fixtures/web-first/chromium/html-opacity-source-root-high.png create mode 100644 fixtures/web-first/chromium/html-opacity-source-root-low.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-blur-absent.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-blur-arc.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-blur-high.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-blur-low.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-blur-srgb.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-blur-zero.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-disabled-ellipse-height.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-disabled-ellipse-width.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-disabled-rect-height.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-disabled-rect-width.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-disabled-rect.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-enclosing-clip.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-leaf-stroke.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-linear-simple.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-pattern-absent.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-pattern-fractional.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-pattern-high.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-pattern-low.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-pattern-mirror.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-pattern-stroke.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-pattern-translated.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-radial-absent.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-radial-fractional.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-radial-high.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-radial-low.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-radial-mirror.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-radial-simple.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-radial-stroke.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-radial-translated.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-radial-uniform.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-root-transparent.png create mode 100644 fixtures/web-first/chromium/svg-opacity-source-use.png create mode 100644 fixtures/web-first/html-opacity-source-host-high.html create mode 100644 fixtures/web-first/html-opacity-source-host-low.html create mode 100644 fixtures/web-first/html-opacity-source-root-high.html create mode 100644 fixtures/web-first/html-opacity-source-root-low.html create mode 100644 fixtures/web-first/svg-opacity-source-blur-absent.svg create mode 100644 fixtures/web-first/svg-opacity-source-blur-arc.svg create mode 100644 fixtures/web-first/svg-opacity-source-blur-high.svg create mode 100644 fixtures/web-first/svg-opacity-source-blur-low.svg create mode 100644 fixtures/web-first/svg-opacity-source-blur-srgb.svg create mode 100644 fixtures/web-first/svg-opacity-source-blur-zero.svg create mode 100644 fixtures/web-first/svg-opacity-source-disabled-ellipse-height.svg create mode 100644 fixtures/web-first/svg-opacity-source-disabled-ellipse-width.svg create mode 100644 fixtures/web-first/svg-opacity-source-disabled-rect-height.svg create mode 100644 fixtures/web-first/svg-opacity-source-disabled-rect-width.svg create mode 100644 fixtures/web-first/svg-opacity-source-disabled-rect.svg create mode 100644 fixtures/web-first/svg-opacity-source-enclosing-clip.svg create mode 100644 fixtures/web-first/svg-opacity-source-leaf-stroke.svg create mode 100644 fixtures/web-first/svg-opacity-source-linear-simple.svg create mode 100644 fixtures/web-first/svg-opacity-source-pattern-absent.svg create mode 100644 fixtures/web-first/svg-opacity-source-pattern-fractional.svg create mode 100644 fixtures/web-first/svg-opacity-source-pattern-high.svg create mode 100644 fixtures/web-first/svg-opacity-source-pattern-low.svg create mode 100644 fixtures/web-first/svg-opacity-source-pattern-mirror.svg create mode 100644 fixtures/web-first/svg-opacity-source-pattern-stroke.svg create mode 100644 fixtures/web-first/svg-opacity-source-pattern-translated.svg create mode 100644 fixtures/web-first/svg-opacity-source-radial-absent.svg create mode 100644 fixtures/web-first/svg-opacity-source-radial-fractional.svg create mode 100644 fixtures/web-first/svg-opacity-source-radial-high.svg create mode 100644 fixtures/web-first/svg-opacity-source-radial-low.svg create mode 100644 fixtures/web-first/svg-opacity-source-radial-mirror.svg create mode 100644 fixtures/web-first/svg-opacity-source-radial-simple.svg create mode 100644 fixtures/web-first/svg-opacity-source-radial-stroke.svg create mode 100644 fixtures/web-first/svg-opacity-source-radial-translated.svg create mode 100644 fixtures/web-first/svg-opacity-source-radial-uniform.svg create mode 100644 fixtures/web-first/svg-opacity-source-root-transparent.svg create mode 100644 fixtures/web-first/svg-opacity-source-use.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-child-transform.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-clipped-contributor.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-complex-stroke.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-enclosing-filter.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-enclosing-mask.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-marker-client.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-mask-program.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-nested-contributor.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-omitted-stroke.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-path.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-pattern-program.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-root-path.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-shear.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-transparent-contributor.svg create mode 100644 fixtures/web-first/unsupported/svg-opacity-source-unequal-scale.svg create mode 100644 fixtures/web-first/unsupported/svg-paint-server-source-filter.svg create mode 100644 fixtures/web-first/unsupported/svg-paint-server-source-mask.svg create mode 100644 packages/grida-reftest/svg-assertions/opacity-source.json diff --git a/.github/workflows/consolidation-gates.yml b/.github/workflows/consolidation-gates.yml index 351b898e..6d73d7ff 100644 --- a/.github/workflows/consolidation-gates.yml +++ b/.github/workflows/consolidation-gates.yml @@ -188,7 +188,7 @@ jobs: pnpm install --frozen-lockfile pnpm -C packages/grida-reftest exec playwright install --with-deps chromium - - name: Verify assertion contracts and the actual n0 CLI pilot + - name: Verify assertion contracts and the actual n0 CLI suites # The registry crate uses its packaged VCS identity for binary downloads. # FORCE_SKIA_BINARIES_DOWNLOAD selects an upstream-checkout test path # which instead builds Skia from source when no Git checkout exists. @@ -198,6 +198,9 @@ jobs: pnpm -C packages/grida-reftest exec tsx svg-assertions/cli.ts \ --manifest "$GITHUB_WORKSPACE/packages/grida-reftest/svg-assertions/pilot.json" \ --out "$RUNNER_TEMP/$SVG_ASSERTION_DIR" + pnpm -C packages/grida-reftest exec tsx svg-assertions/cli.ts \ + --manifest "$GITHUB_WORKSPACE/packages/grida-reftest/svg-assertions/opacity-source.json" \ + --out "$RUNNER_TEMP/$SVG_ASSERTION_DIR/opacity-source" - name: Upload SVG assertion observations if: always() diff --git a/crates/n0/README.md b/crates/n0/README.md index a3fa416b..8ff76f7f 100644 --- a/crates/n0/README.md +++ b/crates/n0/README.md @@ -322,7 +322,19 @@ still apply, including to zero-sized paintless rectangles. This exemption does not admit mapped or nonrectangular paintless geometry; every painted rectangle, including a stroke-only rectangle, must still be enclosed. The glyphless module tests pin these rules and byte-identical changed-view replay. -Ordinary undeclared scopes keep their existing profile. +`ScopeOpacityGroup` can carry the same checked `IsolatedSourceDomain` fact +without attaching an enclosure to the reusable numeric opacity factor. +Its glyphless profile additionally accepts co-mapped rectangles and ellipses, +including radial and repeating paints, with simple local strokes. A declared +opacity source rejects under-enclosure, independently mapped contributors, +paths, complex strokes, nested effects, enclosing filter/mask programs and +repeating-program placement with an owner-bearing error. Completed groups may +still sit inside ordinary opacity or geometric clipping. Both native opacity +restoration and the byte-255 Normal promotion consume the declaration; +neither widens geometry or reuses a previous source image. +`tests/opacity_source_domain.rs` independently tests this contract, changed-view +replay and rejection before destination mutation. These are consumer laws, not +browser pixel assertions. Ordinary undeclared scopes keep their existing profile. The declaration targets the containing stream, independently of a scope item's drawing map. Current-view mapping and device enclosure are recalculated for every execution. Unsupported device bounds return an owner-bearing diff --git a/crates/n0/src/drawlist.rs b/crates/n0/src/drawlist.rs index b698dea1..728d1ae6 100644 --- a/crates/n0/src/drawlist.rs +++ b/crates/n0/src/drawlist.rs @@ -415,6 +415,7 @@ pub enum ItemKind { /// meaning and not a flag. BeginIsolatedOpacity { opacity: f32, + source_domain: Option, }, /// The resolved group's children start against transparent black. Blend /// and opacity apply together when that completed source joins the diff --git a/crates/n0/src/glyphless.rs b/crates/n0/src/glyphless.rs index cb701fc9..8ff127e1 100644 --- a/crates/n0/src/glyphless.rs +++ b/crates/n0/src/glyphless.rs @@ -99,11 +99,22 @@ pub enum BuildError { owner: VisualRef, reason: String, }, + /// A complete isolated source cannot be honored by this consumer profile. + SourceDomain { + owner: VisualRef, + reason: String, + }, } impl std::fmt::Display for BuildError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + BuildError::SourceDomain { owner, reason } => { + write!( + f, + "glyphless source {owner:?} domain preflight failed: {reason}" + ) + } BuildError::InvalidFrameBounds => f.write_str("glyphless frame bounds are invalid"), BuildError::InvalidVisualBounds(owner) => { write!(f, "glyphless visual {owner:?} has invalid frame bounds") @@ -226,13 +237,14 @@ impl FrameProduct { view: &math2::transform::AffineTransform, ) -> Result<(), FrameExecutionError> { for &(owner, domain) in &self.source_domains { - // The admitted declaration map is identity. Validate all view + // Validate the declaration's map followed by the current view // arithmetic before touching the caller's canvas; never ignore a // domain just because its current device bounds cannot be formed. - if rframe::BlendSourceDomain::new(domain.rect(), *view).is_err() { + let mapping = view.compose(&domain.source_to_stream()); + if rframe::IsolatedSourceDomain::new(domain.rect(), mapping).is_err() { return Err(FrameExecutionError::SourceDomain { owner }); } - let bounds = math2::rect_transform(domain.rect(), view); + let bounds = math2::rect_transform(domain.rect(), &mapping); if bounds .corners() .into_iter() @@ -391,12 +403,20 @@ pub fn compile(resolved: Frame) -> Result { // when it closes. let mut open_scopes: Vec = Vec::new(); let mut active_source_domain: Option<(VisualRef, rframe::BlendSourceDomain)> = None; + let mut active_opacity_source_domain: Option<(VisualRef, rframe::IsolatedSourceDomain)> = None; let mut source_domains = Vec::new(); for frame_item in resolved.items.iter() { let node = match frame_item { FrameItem::Node(node) => node, FrameItem::ScopeBegin(scope) => { + if let Some((owner, _)) = active_opacity_source_domain { + return Err(BuildError::SourceDomain { + owner, + reason: "an opacity source declaration does not yet admit nested effects" + .into(), + }); + } if let Some((owner, _)) = active_source_domain { return Err(BuildError::Blend { owner, @@ -412,6 +432,22 @@ pub fn compile(resolved: Frame) -> Result { provenance.owners.push(scope.owner); // Placeholder until the scope closes and its union is known. provenance.coverage.push(None); + let opacity_domain = match &scope.effect { + ScopeEffect::Opacity(group) => group.source_domain(), + _ => None, + }; + if let Some(domain) = opacity_domain { + if open_scopes.iter().any(|scope| { + matches!( + scope.kind, + OpenScopeKind::Mask { .. } | OpenScopeKind::Filter { .. } + ) + }) { + return Err(BuildError::SourceDomain { owner: scope.owner, reason: "an opacity source declaration inside an image effect needs its own materialization profile".into() }); + } + active_opacity_source_domain = Some((scope.owner, domain)); + source_domains.push((scope.owner, domain)); + } // Backend byte-255 opacity takes the same exact Normal // restore as a Blend scope. Retain the original resolved // opacity and the layer itself: byte quantization is not @@ -419,8 +455,13 @@ pub fn compile(resolved: Frame) -> Result { // share preflight, owner coverage, and the matching close. let promoted_opacity = match &scope.effect { ScopeEffect::Opacity(opacity) => { - let blend = - rframe::ScopeBlend::new(rframe::ScopeBlendMode::Normal, Some(*opacity)); + let mut blend = rframe::ScopeBlend::new( + rframe::ScopeBlendMode::Normal, + Some(opacity.opacity()), + ); + if let Some(domain) = opacity_domain { + blend = blend.with_source_domain(domain); + } crate::paint::uses_isolated_byte_blender(blend) .then_some(ScopeEffect::Blend(blend)) } @@ -433,13 +474,16 @@ pub fn compile(resolved: Frame) -> Result { node: slot, world: frame_world, kind: ItemKind::BeginIsolatedOpacity { - opacity: opacity.get(), + opacity: opacity.opacity().get(), + source_domain: opacity_domain, }, }); (OpenScopeKind::Opacity, None) } ScopeEffect::Blend(blend) => { - if let Some(domain) = blend.source_domain() { + if let Some(domain) = + blend.source_domain().filter(|_| opacity_domain.is_none()) + { if domain.source_to_stream() != math2::transform::AffineTransform::identity() || open_scopes.iter().any(|scope| { @@ -544,6 +588,11 @@ pub fn compile(resolved: Frame) -> Result { ), }; let slot = scope.slot; + if active_opacity_source_domain + .is_some_and(|(owner, _)| provenance.owners[slot.index()] == owner) + { + active_opacity_source_domain = None; + } if active_source_domain .is_some_and(|(owner, _)| provenance.owners[slot.index()] == owner) { @@ -564,6 +613,14 @@ pub fn compile(resolved: Frame) -> Result { continue; } FrameItem::MaskBegin(mask) => { + if let Some((owner, _)) = active_opacity_source_domain { + return Err(BuildError::SourceDomain { + owner, + reason: + "an opacity source declaration does not yet admit nested image masks" + .into(), + }); + } if let Some((owner, _)) = active_source_domain { return Err(BuildError::Blend { owner, @@ -693,6 +750,14 @@ pub fn compile(resolved: Frame) -> Result { reason: reason.into(), })?; } + if let Some((owner, domain)) = active_opacity_source_domain { + validate_opacity_source_domain_node(node, domain).map_err(|reason| { + BuildError::SourceDomain { + owner, + reason: reason.into(), + } + })?; + } // The paint reference box is the geometry's own extent. Ordinary box // routes draw at their item origin, so their paint box already starts // there. A path's stream carries absolute local coordinates, so its box @@ -1007,6 +1072,55 @@ fn validate_source_domain_node( Ok(()) } +/// A complete opacity source may contain co-mapped rectangles and ellipses. +/// The declaration is authoritative; this only checks that live coverage fits +/// and that the consumer can honor the source-space materialization profile. +fn validate_opacity_source_domain_node( + node: &rframe::FrameNode, + domain: rframe::IsolatedSourceDomain, +) -> Result<(), &'static str> { + let rect = match node.geometry { + Geometry::Rect(rect) | Geometry::Ellipse(rect) => rect, + Geometry::Path(_) => { + return Err("an opacity source declaration requires rectangular or elliptical geometry") + } + }; + if node.transform != domain.source_to_stream() { + return Err("an opacity source declaration requires co-mapped geometry"); + } + // A zero-area box without a stroke cannot paint outside the declaration. + // Its retained geometry is not a drawable source contribution. + if (rect.width == 0.0 || rect.height == 0.0) && node.stroke.is_none() { + return Ok(()); + } + if node.paints.is_empty() && node.stroke.is_none() { + return Ok(()); + } + let width = if let Some(stroke) = &node.stroke { + if stroke.space() != rframe::StrokeSpace::Local + || stroke.cap() != rframe::StrokeCap::Butt + || stroke.join() != rframe::StrokeJoin::Miter + || stroke.dash().is_some() + || stroke.dash_intervals().is_some() + { + return Err("an opacity source declaration requires a simple local stroke"); + } + stroke.width() + } else { + 0.0 + }; + let half = f64::from(width) / 2.0; + let bounds = domain.rect(); + if f64::from(rect.x) - half < f64::from(bounds.x) + || f64::from(rect.y) - half < f64::from(bounds.y) + || f64::from(rect.x) + f64::from(rect.width) + half > f64::from(bounds.x + bounds.width) + || f64::from(rect.y) + f64::from(rect.height) + half > f64::from(bounds.y + bounds.height) + { + return Err("an opacity source declaration does not enclose its painted geometry"); + } + Ok(()) +} + /// Project the contract's checked command stream into the engine's resolved /// path material. /// @@ -1523,7 +1637,7 @@ fn compile_pattern( reason: format!("nested pattern program failed projection: {error}"), })?; if !product.source_domains.is_empty() { - return Err(BuildError::Paint { owner, reason: "a declared blend source domain inside a repeating program needs its own execution profile".into() }); + return Err(BuildError::Paint { owner, reason: "a declared isolated source domain inside a repeating program needs its own execution profile".into() }); } Ok(Arc::new(ResolvedPattern { width: pattern.width(), @@ -2253,9 +2367,9 @@ mod tests { fn scope_begin(owner: VisualRef, opacity: f32) -> FrameItem { FrameItem::ScopeBegin(Scope { owner, - effect: ScopeEffect::Opacity( + effect: ScopeEffect::Opacity(rframe::ScopeOpacityGroup::new( ScopeOpacity::new(opacity).expect("test opacity is a scope fact"), - ), + )), }) } @@ -3846,7 +3960,10 @@ mod tests { corner_radius: RectangularCornerRadius::default(), corner_smoothing: CornerSmoothing::default(), }), - std::mem::discriminant(&ItemKind::BeginIsolatedOpacity { opacity: 0.5 }), + std::mem::discriminant(&ItemKind::BeginIsolatedOpacity { + opacity: 0.5, + source_domain: None + }), std::mem::discriminant(&ItemKind::RectFill { w: 0.0, h: 0.0, @@ -3860,7 +3977,7 @@ mod tests { ], "frame clip, then the scope enclosing its span" ); - let ItemKind::BeginIsolatedOpacity { opacity } = product.drawlist.items[1].kind else { + let ItemKind::BeginIsolatedOpacity { opacity, .. } = product.drawlist.items[1].kind else { panic!("second item begins the opacity scope"); }; assert_eq!(opacity, 0.5); @@ -3910,7 +4027,10 @@ mod tests { ) } else { ( - ItemKind::BeginIsolatedOpacity { opacity: value }, + ItemKind::BeginIsolatedOpacity { + opacity: value, + source_domain: None, + }, ItemKind::EndOpacity, ) }; diff --git a/crates/n0/src/paint.rs b/crates/n0/src/paint.rs index 6fb74218..793ef66b 100644 --- a/crates/n0/src/paint.rs +++ b/crates/n0/src/paint.rs @@ -3111,6 +3111,16 @@ fn build_filter(filter: &ResolvedFilter) -> Result { _ => inherited_color_restore, }; let source_preflatten = inputs.iter().any(|input| input.source_preflatten) + // Active blur consumes a completed source image. Drawing a native + // ellipse straight into the filtered saveLayer instead changes its + // coverage materialization (including with no enclosing opacity). + // Reuse the existing one-per-source preparation, not a layer per + // primitive. Zero blur and generated inputs need no source image. + || matches!( + &node.primitive, + ResolvedFilterPrimitive::GaussianBlur { sigma_x, sigma_y } + if source_dependent && (*sigma_x > 0.0 || *sigma_y > 0.0) + ) || matches!( &node.primitive, ResolvedFilterPrimitive::ColorMatrix { matrix } @@ -3904,6 +3914,74 @@ mod filter_policy_tests { assert!(generated.restore_blender.is_none()); } + #[test] + fn only_live_source_dependent_blur_preflattens_its_source() { + let blur = |input, sigma_x, sigma_y, color_space| ResolvedFilterNode { + inputs: Arc::from([input]), + region: REGION, + color_space, + primitive: ResolvedFilterPrimitive::GaussianBlur { sigma_x, sigma_y }, + }; + let graph = |nodes: Vec<_>| ResolvedFilter { + region: REGION, + nodes: Arc::from(nodes), + may_paint_transparent_input: false, + source_is_transparent: false, + }; + let generated = || ResolvedFilterNode { + inputs: Arc::from([]), + region: REGION, + color_space: ResolvedFilterColorSpace::Srgb, + primitive: ResolvedFilterPrimitive::SolidColor { + color: Color32F::new(0.2, 0.4, 0.8, 0.5).unwrap(), + }, + }; + for space in [ + ResolvedFilterColorSpace::Srgb, + ResolvedFilterColorSpace::LinearRgb, + ] { + for input in [ + ResolvedFilterInput::Source, + ResolvedFilterInput::SourceAlpha, + ] { + for (x, y, expected) in [(4.0, 4.0, true), (0.0, 4.0, true), (0.0, 0.0, false)] { + assert_eq!( + build_filter(&graph(vec![blur(input, x, y, space)])) + .unwrap() + .source_preflatten, + expected + ); + } + } + assert!( + !build_filter(&graph(vec![ + generated(), + blur(ResolvedFilterInput::Node(0), 4.0, 4.0, space) + ])) + .unwrap() + .source_preflatten + ); + assert!( + !build_filter(&graph(vec![ + blur(ResolvedFilterInput::Source, 4.0, 4.0, space), + generated() + ])) + .unwrap() + .source_preflatten, + "an unreachable blur cannot request source preparation" + ); + assert!( + build_filter(&graph(vec![ + blur(ResolvedFilterInput::Source, 4.0, 4.0, space), + blur(ResolvedFilterInput::Node(0), 0.0, 0.0, space) + ])) + .unwrap() + .source_preflatten, + "a zero operation preserves a live input's preparation" + ); + } + } + #[test] fn only_active_source_dependent_morphology_preflattens_its_source() { let morphology = |input, radius, color_space| ResolvedFilterNode { @@ -4874,6 +4952,9 @@ pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Scope { Opacity, + IsolatedOpacity { + source_clip: bool, + }, Blend { source_clip: bool, #[cfg(feature = "trace")] @@ -4912,18 +4993,35 @@ pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, canvas.save_layer(&layer); scopes.push(Scope::Opacity); } - ItemKind::BeginIsolatedOpacity { opacity } => { + ItemKind::BeginIsolatedOpacity { + opacity, + source_domain, + } => { // The Web's isolated group: the layer starts empty (no // backdrop copy), contents blend among themselves, and the // restore composites the layer source-over modulated by the - // opacity — the plain Skia layer Chromium itself restores - // through, which is what makes the quantization match the - // oracle byte-for-byte. + // opacity. A declared source enclosure additionally fixes the + // raster domain; the layer alone does not establish parity. let mut restore_paint = Paint::default(); restore_paint.set_alpha_f(opacity.clamp(0.0, 1.0)); + if let Some(domain) = source_domain { + use skia_safe::RoundOut; + let [[a, c, e], [b, d, f]] = domain.source_to_stream().matrix; + let mapping = view.then(&Affine { a, b, c, d, e, f }); + let rect = domain.rect(); + let bounds: Rect = skia_matrix(&mapping) + .map_rect(Rect::from_xywh(rect.x, rect.y, rect.width, rect.height)) + .0 + .round_out(); + canvas.save(); + canvas.reset_matrix(); + canvas.clip_rect(bounds, None, false); + } let layer = SaveLayerRec::default().paint(&restore_paint); canvas.save_layer(&layer); - scopes.push(Scope::Opacity); + scopes.push(Scope::IsolatedOpacity { + source_clip: source_domain.is_some(), + }); } ItemKind::BeginIsolatedBlend { blend } => { // One empty-start layer, one restoration. Nesting an opacity @@ -4978,10 +5076,16 @@ pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, } ItemKind::EndOpacity => { let scope = scopes.pop(); - debug_assert_eq!(scope, Some(Scope::Opacity)); + debug_assert!(matches!( + scope, + Some(Scope::Opacity | Scope::IsolatedOpacity { .. }) + )); if scope.is_some() { canvas.restore(); } + if matches!(scope, Some(Scope::IsolatedOpacity { source_clip: true })) { + canvas.restore(); + } } ItemKind::BeginClipRect { w, diff --git a/crates/n0/tests/group_blending.rs b/crates/n0/tests/group_blending.rs index 83ac3de7..14386b5a 100644 --- a/crates/n0/tests/group_blending.rs +++ b/crates/n0/tests/group_blending.rs @@ -13,7 +13,7 @@ use n0::paint::{read_pixels, PaintCtx}; use rframe::{ ClipGeometry, ClipLayer, ClipPath, Frame, FrameItem, FrameItems, FrameNode, Geometry, Identity, Mask, MaskMode, PaintStack, PatternPaint, Provenance, Scope, ScopeBlend, ScopeBlendMode, - ScopeEffect, ScopeOpacity, Stroke, StrokeCap, StrokeJoin, VisualRef, + ScopeEffect, ScopeOpacity, ScopeOpacityGroup, Stroke, StrokeCap, StrokeJoin, VisualRef, }; const SIZE: i32 = 48; @@ -203,7 +203,10 @@ mod layer_metrics { for (value, count) in [(0.998, 0), (0.999, 1), (1.0_f32.next_down(), 1)] { drain_blend_layers(); let product = compile(frame(vec![ - begin(10, ScopeEffect::Opacity(ScopeOpacity::new(value).unwrap())), + begin( + 10, + ScopeEffect::Opacity(ScopeOpacityGroup::new(ScopeOpacity::new(value).unwrap())), + ), solid(1, rect(8.0, 8.0, 24.0, 24.0), FIRST), FrameItem::ScopeEnd, ])) @@ -686,7 +689,10 @@ fn combined_opacity_and_blend_differ_from_an_outer_opacity_scope() { ])) .unwrap(); let nested = compile(frame(vec![ - begin(12, ScopeEffect::Opacity(ScopeOpacity::new(0.6).unwrap())), + begin( + 12, + ScopeEffect::Opacity(ScopeOpacityGroup::new(ScopeOpacity::new(0.6).unwrap())), + ), blend(10, mode, None), shape, FrameItem::ScopeEnd, @@ -724,7 +730,7 @@ fn normal_blend_with_opacity_matches_existing_isolated_opacity() { compile(frame(items)).unwrap() }; let opacity = ScopeOpacity::new(opacity).unwrap(); - let old = scene(ScopeEffect::Opacity(opacity)); + let old = scene(ScopeEffect::Opacity(ScopeOpacityGroup::new(opacity))); let new = scene(ScopeEffect::Blend(ScopeBlend::new( ScopeBlendMode::Normal, Some(opacity), @@ -803,7 +809,7 @@ fn near_unit_normal_spellings_share_exact_partial_alpha_restore_and_retain_the_f for value in [0.999, 1.0_f32.next_down()] { let opacity = ScopeOpacity::new(value).unwrap(); for effect in [ - ScopeEffect::Opacity(opacity), + ScopeEffect::Opacity(ScopeOpacityGroup::new(opacity)), ScopeEffect::Blend(ScopeBlend::new(ScopeBlendMode::Normal, Some(opacity))), ] { let resolved = scene(effect); @@ -865,7 +871,7 @@ fn opacity_byte_254_to_255_keeps_scope_damage_coverage_and_retained_matches_fres let effect = if blend_spelling { ScopeEffect::Blend(ScopeBlend::new(ScopeBlendMode::Normal, Some(opacity))) } else { - ScopeEffect::Opacity(opacity) + ScopeEffect::Opacity(ScopeOpacityGroup::new(opacity)) }; frame(vec![ begin(10, effect), diff --git a/crates/n0/tests/opacity_source_domain.rs b/crates/n0/tests/opacity_source_domain.rs new file mode 100644 index 00000000..446640e2 --- /dev/null +++ b/crates/n0/tests/opacity_source_domain.rs @@ -0,0 +1,280 @@ +//! Independent illustration-producer laws for complete opacity sources. +//! These are consumer and reuse laws, not browser conformance measurements. + +use cg::{CGColor, GradientStop, Paint, Paints, RadialGradientPaint}; +use math2::{transform::AffineTransform, Rectangle}; +use n0::{ + glyphless::{self, BuildError}, + paint::{read_pixels, PaintCtx}, +}; +use rframe::{ + Frame, FrameItem, FrameItems, FrameNode, Geometry, Identity, IsolatedSourceDomain, PaintStack, + Provenance, Scope, ScopeEffect, ScopeOpacity, ScopeOpacityGroup, VisualRef, +}; + +fn owner(id: u64) -> VisualRef { + VisualRef::new(Identity::new(id), Provenance::new(id + 100)) +} + +fn domain(map: AffineTransform) -> IsolatedSourceDomain { + IsolatedSourceDomain::new(Rectangle::from_xywh(6.0, 6.0, 50.0, 48.0), map).unwrap() +} + +fn illustration(alpha: f32, source: Option, map: AffineTransform) -> Frame { + let mut group = ScopeOpacityGroup::new(ScopeOpacity::new(alpha).unwrap()); + if let Some(source) = source { + group = group.with_source_domain(source); + } + let circle = Rectangle::from_xywh(6.0, 6.0, 48.0, 48.0); + let sibling = Rectangle::from_xywh(38.0, 34.0, 18.0, 20.0); + let paints = + PaintStack::try_from_paints(Paints::new([Paint::RadialGradient(RadialGradientPaint { + stops: vec![ + GradientStop { + offset: 0.0, + color: CGColor::from_rgba(215, 104, 67, 255).into(), + }, + GradientStop { + offset: 1.0, + color: CGColor::from_rgba(91, 172, 225, 102).into(), + }, + ], + ..Default::default() + })])) + .unwrap(); + Frame { + owner: owner(0), + bounds: Rectangle::from_xywh(0.0, 0.0, 64.0, 64.0), + items: FrameItems::try_new(vec![ + FrameItem::ScopeBegin(Scope { + owner: owner(1), + effect: ScopeEffect::Opacity(group), + }), + FrameItem::Node(FrameNode { + owner: owner(2), + transform: map, + geometry: Geometry::Ellipse(circle), + bounds: math2::rect_transform(circle, &map), + paints, + stroke: None, + }), + FrameItem::Node(FrameNode { + owner: owner(3), + transform: map, + geometry: Geometry::Rect(sibling), + bounds: math2::rect_transform(sibling, &map), + paints: PaintStack::solid(CGColor::from_rgba(91, 172, 225, 153)), + stroke: None, + }), + FrameItem::ScopeEnd, + ]) + .unwrap(), + } +} + +fn raster(product: &glyphless::FrameProduct, view: AffineTransform) -> Vec { + let mut surface = skia_safe::surfaces::raster_n32_premul((64, 64)).unwrap(); + surface + .canvas() + .clear(skia_safe::Color::from_rgb(66, 101, 137)); + let saves = surface.canvas().save_count(); + product + .execute(surface.canvas(), &view, &PaintCtx::new(None)) + .unwrap(); + assert_eq!(surface.canvas().save_count(), saves); + read_pixels(&mut surface, 64, 64) +} + +#[test] +fn the_declared_source_changes_raster_without_changing_painted_geometry() { + let map = AffineTransform::identity(); + for alpha in [0.998, 0.999] { + let ordinary = illustration(alpha, None, map); + let declared = illustration(alpha, Some(domain(map)), map); + assert_eq!(ordinary.nodes(), declared.nodes()); + let a = glyphless::compile(ordinary).unwrap(); + let b = glyphless::compile(declared).unwrap(); + assert_ne!(raster(&a, map), raster(&b, map)); + let damage = glyphless::diff_frame(&a, &b); + assert!(damage.changed.contains(&owner(1))); + } +} + +#[test] +fn a_mapped_source_replayed_at_a_changed_view_matches_fresh_with_balanced_saves() { + let map = AffineTransform::from_acebdf( + 0.9659258, + -0.25881904, + 9.372583, + 0.25881904, + 0.9659258, + -7.1918354, + ); + for alpha in [0.57, 0.998, 0.999] { + let frame = illustration(alpha, Some(domain(map)), map); + let retained = glyphless::compile(frame.clone()).unwrap(); + for view in [ + AffineTransform::identity(), + AffineTransform::from_acebdf(0.75, 0.0, 1.25, 0.0, 1.125, -0.5), + ] { + let fresh = glyphless::compile(frame.clone()).unwrap(); + assert_eq!(raster(&retained, view), raster(&fresh, view)); + } + } +} + +#[test] +fn invalid_view_refuses_before_mutating_the_destination() { + let map = AffineTransform::identity(); + let product = glyphless::compile(illustration(0.999, Some(domain(map)), map)).unwrap(); + let mut surface = skia_safe::surfaces::raster_n32_premul((64, 64)).unwrap(); + surface.canvas().clear(skia_safe::Color::MAGENTA); + let before = read_pixels(&mut surface, 64, 64); + let saves = surface.canvas().save_count(); + let bad = AffineTransform::from_acebdf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + assert!(product + .execute(surface.canvas(), &bad, &PaintCtx::new(None)) + .is_err()); + assert_eq!(surface.canvas().save_count(), saves); + assert_eq!(read_pixels(&mut surface, 64, 64), before); +} + +#[test] +fn zero_area_unstroked_boxes_do_not_require_phantom_source_area() { + let map = AffineTransform::identity(); + for alpha in [0.998, 0.999] { + let base = illustration(alpha, Some(domain(map)), map); + let expected = raster(&glyphless::compile(base.clone()).unwrap(), map); + for (width, height) in [(0.0, 0.0), (0.0, 20.0), (20.0, 0.0)] { + let bounds = Rectangle::from_xywh(0.25, 0.25, width, height); + for geometry in [Geometry::Rect(bounds), Geometry::Ellipse(bounds)] { + let mut disabled = base.nodes()[1].clone(); + disabled.owner = owner(4); + disabled.geometry = geometry; + disabled.bounds = bounds; + let mut items: Vec<_> = base.items.iter().cloned().collect(); + items.insert(3, FrameItem::Node(disabled.clone())); + let frame = Frame { + items: FrameItems::try_new(items).unwrap(), + ..base.clone() + }; + assert_eq!(frame.nodes()[2], &disabled); + assert_eq!(raster(&glyphless::compile(frame).unwrap(), map), expected); + } + } + } +} + +#[test] +fn incomplete_and_unconsumable_declarations_refuse_by_owner() { + let map = AffineTransform::identity(); + let small = IsolatedSourceDomain::new(Rectangle::from_xywh(8.0, 8.0, 40.0, 40.0), map).unwrap(); + let moved = domain(AffineTransform::from_acebdf(1.0, 0.0, 1.0, 0.0, 1.0, 0.0)); + for source in [small, moved] { + let result = glyphless::compile(illustration(0.999, Some(source), map)); + assert!( + matches!(result, Err(BuildError::SourceDomain { owner: found, .. }) if found == owner(1)) + ); + } + let frame = illustration(0.998, Some(domain(map)), map); + let mut items: Vec<_> = frame.items.iter().cloned().collect(); + items.insert( + 1, + FrameItem::ScopeBegin(Scope { + owner: owner(4), + effect: ScopeEffect::Opacity(ScopeOpacityGroup::new(ScopeOpacity::new(0.5).unwrap())), + }), + ); + items.insert(3, FrameItem::ScopeEnd); + let frame = Frame { + items: FrameItems::try_new(items).unwrap(), + ..frame + }; + assert!( + matches!(glyphless::compile(frame), Err(BuildError::SourceDomain { owner: found, .. }) if found == owner(1)) + ); +} + +#[test] +fn image_effect_and_repeating_program_placement_refuse_by_owner() { + use rframe::{ + ClipGeometry, ClipLayer, ClipPath, Filter, FilterColorSpace, FilterInput, FilterNode, + FilterPrimitive, FilterProgram, Mask, MaskMode, PatternPaint, + }; + use std::sync::Arc; + let map = AffineTransform::identity(); + let frame = illustration(0.999, Some(domain(map)), map); + let region = Rectangle::from_xywh(0.0, 0.0, 64.0, 64.0); + let program = FilterProgram::new(Arc::from([FilterNode::new( + Arc::from([FilterInput::Source]), + region, + FilterColorSpace::Srgb, + FilterPrimitive::GaussianBlur { + sigma_x: 4.0, + sigma_y: 4.0, + }, + )])) + .unwrap(); + let filter = Filter::new(map, region, program).unwrap(); + let mut filtered: Vec<_> = frame.items.iter().cloned().collect(); + filtered.insert( + 0, + FrameItem::ScopeBegin(Scope { + owner: owner(5), + effect: ScopeEffect::Filter(filter), + }), + ); + filtered.push(FrameItem::ScopeEnd); + let filtered = Frame { + items: FrameItems::try_new(filtered).unwrap(), + ..frame.clone() + }; + assert!( + matches!(glyphless::compile(filtered), Err(BuildError::SourceDomain { owner: found, .. }) if found == owner(1)) + ); + + let clip = ClipPath::new(vec![ClipLayer::new(vec![ClipGeometry::new( + map, + Geometry::Rect(region), + ) + .unwrap()]) + .unwrap()]) + .unwrap(); + let mask = FrameItem::MaskBegin(Mask::new(owner(5), MaskMode::Alpha, clip)); + let mut source = frame.nodes()[1].clone(); + source.owner = owner(6); + for nested in [false, true] { + let mut masked: Vec<_> = frame.items.iter().cloned().collect(); + if nested { + masked.insert(1, mask.clone()); + masked.insert(3, FrameItem::MaskSource); + masked.insert(4, FrameItem::Node(source.clone())); + masked.insert(5, FrameItem::MaskEnd); + } else { + masked.insert(0, mask.clone()); + masked.extend([ + FrameItem::MaskSource, + FrameItem::Node(source.clone()), + FrameItem::MaskEnd, + ]); + } + let masked = Frame { + items: FrameItems::try_new(masked).unwrap(), + ..frame.clone() + }; + assert!( + matches!(glyphless::compile(masked), Err(BuildError::SourceDomain { owner: found, .. }) if found == owner(1)) + ); + } + let pattern = PatternPaint::new(64.0, 64.0, map, Arc::new(frame.items.clone()), 1.0).unwrap(); + let mut target = source; + target.owner = owner(10); + target.paints = PaintStack::from_pattern(pattern); + let repeated = Frame { + items: FrameItems::try_new(vec![FrameItem::Node(target)]).unwrap(), + ..frame + }; + assert!( + matches!(glyphless::compile(repeated), Err(BuildError::Paint { owner: found, reason }) if found == owner(10) && reason.contains("source domain inside a repeating program")) + ); +} diff --git a/crates/n0_cli/README.md b/crates/n0_cli/README.md index 783e05d5..96ca9066 100644 --- a/crates/n0_cli/README.md +++ b/crates/n0_cli/README.md @@ -602,15 +602,15 @@ cargo run -p n0_cli --bin n0 -- \ tolerance. The forty-one-cell convolution rung keeps the complete 741-cell gate byte-exact on ARM and hosted x86 without a new tolerance. The seventy-one-cell diffuse-lighting rung keeps the complete 812-cell gate - byte-exact without a new tolerance. All four hundred fifty-three + byte-exact without a new tolerance. All four hundred fifty-nine Chromium-baked filter cells are exact. - The filter estate contains 26 chassis/blur cells, 60 shadow-graph, 28 native + The filter estate contains 32 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,539 Chromium-baked cells plus + cells. The complete primitive corpus contains 1,575 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 351 rows. `feFlood`, `feComposite`, + Bungee), and the named refusal register has 368 rows. `feFlood`, `feComposite`, `feMerge`, `feMergeNode`, `feDropShadow`, `feColorMatrix`, `feComponentTransfer`, `feBlend`, `feMorphology`, `feConvolveMatrix`, `feDiffuseLighting`, `feDistantLight`, `fePointLight`, `feSpotLight`, @@ -831,8 +831,9 @@ cargo run -p n0_cli --bin n0 -- \ interior paints nothing. Default fill plus stroke therefore composites through the isolated post-coverage layer in direct, inline/stylesheet CSS, inherited-container, ``, and `` routes; explicit `fill="none"` - leaves the single stroke fold. Both opacity checklist rows are closed and - the former line-coverage refusal has graduated. + leaves the single stroke fold. The former line-coverage refusal has + graduated; both opacity checklist rows are open again for the isolated + paint-server source profile below. Everything that is genuinely a group composites through a real isolated layer: a shape's fill and stroke together, a group of several draws, nested opacities (which quantize per layer and never flatten to a product — measured @@ -843,6 +844,26 @@ cargo run -p n0_cli --bin n0 -- \ entries. Every non-identity HTML ancestor opacity is a distinct outer scope around the selected inline SVG; explicit `inherit` on the SVG compounds with those host scopes rather than flattening them. + Ordinary isolated ramp/pattern sources carry a complete local drawable + enclosure across `rframe`, separate from geometry and damage. The current + producer profile is co-mapped rectangles/ellipses and simple local strokes, + with translation, rotation/reflection and uniform scale. It encloses each + drawable locally before applying the group map. Both near-unit restoration + routes consume that source fact; root and HTML-ancestor opacity use it too. + Retained zero-area rectangles/ellipses without resolved strokes contribute + no drawable source area; fractional coordinates do not turn them into + nonempty enclosure cells. + Independently mapped contributors, shear/unequal scale, paths, complex + strokes, unresolved or omitted contributors, nested source effects, enclosing + filter/mask placement, resource-program placement and combined marker clients + retain named `opacity source-extent` refusals. An affected element is skipped transactionally + in best effort; a root/host source failure is document-level in both admissions. + These gaps do not narrow the numeric `` parser or add a matcher. + They prevent a broad opacity claim while the source-composition work remains + open in [gridaco/nothing#136](https://github.com/gridaco/nothing/issues/136). + A bare multi-operation paint-server source feeding a filter or mask also + refuses by the separate `paint-server source-extent` name, even at element + opacity one. Completed child images are not bare paint-server passes. CSS `mix-blend-mode: normal | multiply | screen` and `isolation: auto | isolate` have a bounded static SVG group profile. One Stylo computed value decides the operation; raw attribute lookalikes are inert. Neutral/default diff --git a/crates/rframe/README.md b/crates/rframe/README.md index 42cd8228..52cda493 100644 --- a/crates/rframe/README.md +++ b/crates/rframe/README.md @@ -53,11 +53,24 @@ Fill/stroke overlap is already part of that completed group. A separate outer opacity scope around a blend scope states a different nesting and backdrop; it is not an equivalent spelling of combined blend and opacity. -`BlendSourceDomain::new(rect, source_to_stream)` checks one strictly positive, +`ScopeEffect::Opacity(ScopeOpacityGroup)` states ordinary isolated opacity. +`ScopeOpacityGroup::new(opacity)` takes the existing checked `ScopeOpacity` +factor. Its children paint in order against transparent black; the completed +source's premultiplied color and alpha receive the factor once before normal +source-over composition. `opacity()` returns that exact factor. A group cannot +be used as `ScopeBlend`'s optional opacity: `ScopeOpacity` stays numeric, so a +blend has only its own source declaration. Ordinary opacity retains its own +effect variant and does not become an explicit normal blend. + +`IsolatedSourceDomain::new(rect, source_to_stream)` checks one strictly positive, already-enclosed source-local rectangle and its map into the containing item -stream. `ScopeBlend::with_source_domain(domain)` attaches this complete source -declaration to the existing boundary. `source_domain()` returns it; the ordinary -`ScopeBlend::new` leaves it absent, making no completeness assertion. +stream. Both `ScopeOpacityGroup::with_source_domain(domain)` and +`ScopeBlend::with_source_domain(domain)` attach this complete source declaration +to their existing boundary. Their `source_domain()` returns it; both ordinary +constructors leave it absent, making no completeness assertion. Attachment adds +no nested scope or extra final operation. `BlendSourceDomain` and +`BlendSourceDomainError` remain compatibility aliases for `IsolatedSourceDomain` +and `IsolatedSourceDomainError`; the numerical checks are unchanged. The source materializes against transparent black over the declared domain before that boundary's final opacity/blend. Contribution discovery and local @@ -86,9 +99,11 @@ supported finite affine inverse, and the mapped corners and their enclosing box must remain finite and positive. The existing affine inverse's small-determinant refusal applies. No integer coordinate rule or enclosure algorithm is imposed on the source's unit system. -Empty domains are refused; a domain never makes an empty blend scope meaningful. +Empty domains are refused; a domain never makes an empty opacity or blend scope +meaningful. Producer-only laws, including independent illustration construction, live in -[`tests/blend_source_domain.rs`](tests/blend_source_domain.rs). +[`tests/blend_source_domain.rs`](tests/blend_source_domain.rs) and +[`tests/opacity_source_domain.rs`](tests/opacity_source_domain.rs). `ScopeBlend::new(mode, opacity)` takes `ScopeBlendMode::{Normal, Multiply, Screen}` and `Option`. `mode()` and `opacity()` return those @@ -96,8 +111,9 @@ facts unchanged. `None` means opacity 1; `Some` reuses the finite, strictly between-zero-and-one `ScopeOpacity` check. Zero opacity resolves to no emitted group. In particular, `Normal` with `None` **retains isolation**; a group whose children should paint directly into the enclosing backdrop has no scope. -The existing `ScopeEffect::Opacity` still means isolated normal composition -at its checked opacity, and its constructor still rejects both 0 and 1. +`ScopeOpacity::new` still rejects both 0 and 1. To construct an ordinary opacity +effect, wrap that factor in `ScopeOpacityGroup::new` before passing it to +`ScopeEffect::Opacity`; read the factor through the group's `opacity().get()`. The scope-specific blend enum admits only these three functions. Reusing all of `cg::BlendMode` would admit modes beyond this contract; reusing @@ -133,6 +149,10 @@ helper never understates the mathematical bound. format is `format/grida.fbs`. - **Not a renderer.** The crate is backend-free, and a test locks that: no Skia, no canvas, no paint call can enter it. +- **Not a source enclosure resolver.** An isolated source domain is a complete + producer declaration. Numerical validation cannot establish completeness or + recover non-painted contributions. Geometry bounds, damage bounds, clips, + host views, device grids, and allocation hints cannot stand in for that fact. - **Not a second engine's contract.** It has exactly one consumer. The source-neutrality claim is checked by a canary that feeds the kernel from a second, independent producer (`crates/n0/tests/glyphless_canary.rs`) — the diff --git a/crates/rframe/src/lib.rs b/crates/rframe/src/lib.rs index 6161eee0..cc09bd12 100644 --- a/crates/rframe/src/lib.rs +++ b/crates/rframe/src/lib.rs @@ -38,8 +38,9 @@ pub use frame::{ pub use mask::{Mask, MaskMode}; pub use path::{FillRule, PathCommand, PathData, PathDataError}; pub use scope::{ - BlendSourceDomain, BlendSourceDomainError, Scope, ScopeBlend, ScopeBlendMode, ScopeEffect, - ScopeOpacity, ScopeOpacityError, + BlendSourceDomain, BlendSourceDomainError, IsolatedSourceDomain, IsolatedSourceDomainError, + Scope, ScopeBlend, ScopeBlendMode, ScopeEffect, ScopeOpacity, ScopeOpacityError, + ScopeOpacityGroup, }; pub use stroke::{ Stroke, StrokeCap, StrokeDash, StrokeDashError, StrokeDashIntervals, StrokeDashIntervalsError, diff --git a/crates/rframe/src/scope.rs b/crates/rframe/src/scope.rs index ee70fa87..5fc8fafc 100644 --- a/crates/rframe/src/scope.rs +++ b/crates/rframe/src/scope.rs @@ -46,7 +46,10 @@ impl std::fmt::Display for ScopeOpacityError { impl std::error::Error for ScopeOpacityError {} -/// A checked group opacity: finite and strictly between 0 and 1. +/// A checked group opacity factor: finite and strictly between 0 and 1. +/// +/// This is numeric only. The ordinary isolated group is [`ScopeOpacityGroup`]; +/// a [`ScopeBlend`] reuses this factor but owns its own source declaration. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ScopeOpacity(f32); @@ -81,9 +84,9 @@ pub enum ScopeBlendMode { Screen, } -/// Why a complete blend-source domain cannot cross the resolved contract. +/// Why a complete isolated-source domain cannot cross the resolved contract. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BlendSourceDomainError { +pub enum IsolatedSourceDomainError { /// The local rectangle is non-finite, empty, or has unrepresentable endpoints. InvalidRectangle, /// The map is non-finite or has no supported finite inverse. @@ -92,30 +95,31 @@ pub enum BlendSourceDomainError { InvalidMappedBounds, } -impl std::fmt::Display for BlendSourceDomainError { +impl std::fmt::Display for IsolatedSourceDomainError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::InvalidRectangle => { - "a blend-source domain must have finite local bounds with positive extents and ordered endpoints" + "an isolated-source domain must have finite local bounds with positive extents and ordered endpoints" } Self::InvalidTransform => { - "a blend-source domain map must have finite members and a supported finite inverse" + "an isolated-source domain map must have finite members and a supported finite inverse" } Self::InvalidMappedBounds => { - "a blend-source domain must map to finite, strictly positive bounds" + "an isolated-source domain must map to finite, strictly positive bounds" } }) } } -impl std::error::Error for BlendSourceDomainError {} +impl std::error::Error for IsolatedSourceDomainError {} -/// The complete, already-enclosed local domain of one isolated blend source. +/// The complete, already-enclosed local domain of one isolated group source. /// /// The producer has finished discovering contributions and enclosing them in /// source-local coordinates. Materialize the isolated source over this domain -/// against transparent black, then apply the owning [`ScopeBlend`]'s final -/// opacity and blend. The domain includes the producer's resolved non-painted +/// against transparent black, then apply the owning [`ScopeOpacityGroup`]'s +/// final opacity or [`ScopeBlend`]'s combined final opacity and blend. +/// The domain includes the producer's resolved non-painted /// extent contributions; it is neither a tight geometry box nor a supplemental /// margin. Reconstructing it from visible paints, enlarging it conservatively, /// or enclosing contributors after mapping states a different source. @@ -123,7 +127,7 @@ impl std::error::Error for BlendSourceDomainError {} /// `source_to_stream` maps the already-enclosed rectangle into its containing /// [`crate::FrameItems`] coordinates: frame space for a frame, tile-local space /// for a repeating program. Child node transforms keep their existing meaning; -/// this mapping is not an inherited transform. Each nested blend boundary owns +/// this mapping is not an inherited transform. Each nested isolation boundary owns /// its own declaration and completed source; it does not donate its descendants /// as fresh geometry to an enclosing boundary. /// @@ -137,15 +141,16 @@ impl std::error::Error for BlendSourceDomainError {} /// nor proves that the producer's declaration is complete. There is no integer /// coordinate requirement: the producer has resolved the enclosure in its own /// source space, whose unit need not be a device pixel. Empty domains are not -/// admitted; absence is represented by [`ScopeBlend::source_domain`] returning -/// `None`, which makes no completeness assertion. +/// admitted; absence is represented by [`ScopeOpacityGroup::source_domain`] or +/// [`ScopeBlend::source_domain`] returning `None`, which makes no completeness +/// assertion. #[derive(Clone, Copy, Debug, PartialEq)] -pub struct BlendSourceDomain { +pub struct IsolatedSourceDomain { rect: Rectangle, source_to_stream: AffineTransform, } -impl BlendSourceDomain { +impl IsolatedSourceDomain { /// Check an already-enclosed rectangle and retain both supplied facts exactly. /// /// Local endpoints must be finite and strictly ordered in `f32`. The map @@ -157,9 +162,9 @@ impl BlendSourceDomain { pub fn new( rect: Rectangle, source_to_stream: AffineTransform, - ) -> Result { + ) -> Result { if !valid_domain_rectangle(rect) { - return Err(BlendSourceDomainError::InvalidRectangle); + return Err(IsolatedSourceDomainError::InvalidRectangle); } let [[a, c, _], [b, d, _]] = source_to_stream.matrix; if !source_to_stream @@ -172,7 +177,7 @@ impl BlendSourceDomain { .inverse() .is_some_and(|inverse| inverse.matrix.into_iter().flatten().all(f32::is_finite)) { - return Err(BlendSourceDomainError::InvalidTransform); + return Err(IsolatedSourceDomainError::InvalidTransform); } let corners = rect .corners() @@ -181,7 +186,7 @@ impl BlendSourceDomain { if !corners.into_iter().flatten().all(f32::is_finite) || !valid_domain_rectangle(Rectangle::from_points(&corners)) { - return Err(BlendSourceDomainError::InvalidMappedBounds); + return Err(IsolatedSourceDomainError::InvalidMappedBounds); } Ok(Self { rect, @@ -202,6 +207,12 @@ impl BlendSourceDomain { } } +/// Compatibility name for [`IsolatedSourceDomain`], shared by blend and opacity. +pub use IsolatedSourceDomain as BlendSourceDomain; + +/// Compatibility name for [`IsolatedSourceDomainError`]. +pub use IsolatedSourceDomainError as BlendSourceDomainError; + fn valid_domain_rectangle(rect: Rectangle) -> bool { rect.x.is_finite() && rect.y.is_finite() @@ -215,6 +226,79 @@ fn valid_domain_rectangle(rect: Rectangle) -> bool { && rect.y + rect.height > rect.y } +/// One isolated group's final opacity and optional complete source domain. +/// +/// Children paint in stream order against transparent black. Apply the checked +/// opacity once to the completed source's premultiplied color and alpha, then +/// composite source-over into the enclosing backdrop. The factor is never +/// distributed over child paints, fills, or strokes. +/// +/// An optional [`IsolatedSourceDomain`] declares where that completed source +/// materializes before final opacity. It adds no nested operation, paint, clip, +/// or inherited transform, and cannot make an empty group meaningful. Absence +/// makes no completeness assertion. Numerical checks do not prove completeness; +/// the producer establishes it, and a consumer must honor the declaration or +/// refuse it. Backdrop-preserving opacity and execution policy are inexpressible. +/// +/// [`ScopeOpacity`] remains the numeric factor shared with [`ScopeBlend`]. A +/// blend's optional opacity cannot carry a competing source declaration: +/// +/// ```compile_fail +/// use rframe::{ScopeBlend, ScopeBlendMode, ScopeOpacity, ScopeOpacityGroup}; +/// let group = ScopeOpacityGroup::new(ScopeOpacity::new(0.5).unwrap()); +/// let blend = ScopeBlend::new(ScopeBlendMode::Multiply, Some(group)); +/// ``` +/// +/// ``` +/// use math2::{Rectangle, transform::AffineTransform}; +/// use rframe::{IsolatedSourceDomain, ScopeEffect, ScopeOpacity, ScopeOpacityGroup}; +/// let factor = ScopeOpacity::new(0.375)?; +/// let source = IsolatedSourceDomain::new( +/// Rectangle::from_xywh(-2.0, -3.0, 24.0, 20.0), +/// AffineTransform::identity(), +/// )?; +/// let group = ScopeOpacityGroup::new(factor).with_source_domain(source); +/// assert_eq!(group.opacity(), factor); +/// assert_eq!(group.source_domain(), Some(source)); +/// let effect = ScopeEffect::Opacity(group); +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScopeOpacityGroup { + opacity: ScopeOpacity, + source_domain: Option, +} + +impl ScopeOpacityGroup { + /// State ordinary isolated opacity with no source completeness assertion. + #[must_use] + pub const fn new(opacity: ScopeOpacity) -> Self { + Self { + opacity, + source_domain: None, + } + } + + /// Declare the complete source domain without changing the final operation. + #[must_use] + pub const fn with_source_domain(mut self, domain: IsolatedSourceDomain) -> Self { + self.source_domain = Some(domain); + self + } + + /// The final factor, applied once to the completed source. + #[must_use] + pub const fn opacity(self) -> ScopeOpacity { + self.opacity + } + + /// The declared complete source domain, or no completeness assertion. + #[must_use] + pub const fn source_domain(self) -> Option { + self.source_domain + } +} + /// One isolated group's combined final blend and optional opacity. /// /// Children paint in order against transparent black. Their completed @@ -238,7 +322,7 @@ fn valid_domain_rectangle(rect: Rectangle) -> bool { /// This names visual meaning, never a layer allocation, backdrop copy, cache /// policy, or authored group. /// -/// An optional [`BlendSourceDomain`] states the complete source domain before +/// An optional [`IsolatedSourceDomain`] states the complete source domain before /// this final operation. Absence preserves the existing group meaning without /// asserting completeness. A domain neither creates another scope nor makes an /// empty group meaningful, and equality of domains does not erase the backdrop @@ -260,7 +344,7 @@ fn valid_domain_rectangle(rect: Rectangle) -> bool { pub struct ScopeBlend { mode: ScopeBlendMode, opacity: Option, - source_domain: Option, + source_domain: Option, } impl ScopeBlend { @@ -277,14 +361,14 @@ impl ScopeBlend { /// Declare the complete source domain without changing the final operation. #[must_use] - pub const fn with_source_domain(mut self, domain: BlendSourceDomain) -> Self { + pub const fn with_source_domain(mut self, domain: IsolatedSourceDomain) -> Self { self.source_domain = Some(domain); self } /// The declared complete source domain, or no completeness assertion. #[must_use] - pub const fn source_domain(self) -> Option { + pub const fn source_domain(self) -> Option { self.source_domain } @@ -304,8 +388,9 @@ impl ScopeBlend { /// The compositing effect a scope applies to its group's composite. #[derive(Clone, Debug, PartialEq)] pub enum ScopeEffect { - /// The group composites at this opacity through one isolated layer. - Opacity(ScopeOpacity), + /// Materialize one isolated source, optionally over its declared complete + /// domain, then apply final opacity once and composite source-over. + Opacity(ScopeOpacityGroup), /// Isolate the group, then blend its completed composite with the enclosing /// backdrop at the optional opacity in one final operation. Normal blend /// at unit opacity still retains this scope's isolation boundary. diff --git a/crates/rframe/tests/blend_contract.rs b/crates/rframe/tests/blend_contract.rs index e23baccb..06239991 100644 --- a/crates/rframe/tests/blend_contract.rs +++ b/crates/rframe/tests/blend_contract.rs @@ -14,7 +14,7 @@ use rframe::{ FilterPrimitive, FilterProgram, Frame, FrameItem, FrameItems, FrameItemsError, FrameNode, Geometry, Identity, MAX_PATTERN_DEPTH, MAX_SCOPE_DEPTH, Mask, MaskMode, PaintAlphaFactor, PaintStack, PaintStackError, PatternPaint, PatternPaintError, Provenance, Scope, ScopeBlend, - ScopeBlendMode, ScopeEffect, ScopeOpacity, VisualRef, + ScopeBlendMode, ScopeEffect, ScopeOpacity, ScopeOpacityGroup, VisualRef, }; const MODES: [ScopeBlendMode; 3] = [ @@ -202,7 +202,10 @@ fn combined_blend_and_opacity_is_distinct_from_two_nested_operations() { ))); let blended = diagram(Some(ScopeBlend::new(ScopeBlendMode::Multiply, None))); let mut nested = blended.clone(); - let mut items = vec![begin(101, ScopeEffect::Opacity(opacity))]; + let mut items = vec![begin( + 101, + ScopeEffect::Opacity(ScopeOpacityGroup::new(opacity)), + )]; items.extend(blended.items.iter().cloned()); items.push(FrameItem::ScopeEnd); nested.items = checked(items); @@ -449,7 +452,10 @@ fn blending_preserves_opacity_clip_filter_and_mask_programs() { .unwrap(); let filter = Filter::new(AffineTransform::identity(), rect(), program).unwrap(); let legacy = checked(vec![ - begin(1, ScopeEffect::Opacity(ScopeOpacity::new(0.5).unwrap())), + begin( + 1, + ScopeEffect::Opacity(ScopeOpacityGroup::new(ScopeOpacity::new(0.5).unwrap())), + ), begin(2, ScopeEffect::Clip(clip())), begin(3, ScopeEffect::Filter(filter)), mask(4, MaskMode::Luminance), diff --git a/crates/rframe/tests/mask_contract.rs b/crates/rframe/tests/mask_contract.rs index d4abae26..d42acf4e 100644 --- a/crates/rframe/tests/mask_contract.rs +++ b/crates/rframe/tests/mask_contract.rs @@ -5,7 +5,8 @@ use math2::Rectangle; use math2::transform::AffineTransform; use rframe::{ ClipGeometry, ClipLayer, ClipPath, FrameItem, FrameItems, FrameItemsError, FrameNode, Geometry, - Identity, Mask, MaskMode, PaintStack, Provenance, Scope, ScopeEffect, ScopeOpacity, VisualRef, + Identity, Mask, MaskMode, PaintStack, Provenance, Scope, ScopeEffect, ScopeOpacity, + ScopeOpacityGroup, VisualRef, }; fn owner(id: u64) -> VisualRef { @@ -36,7 +37,9 @@ fn mask(id: u64, mode: MaskMode) -> FrameItem { fn scope(id: u64) -> FrameItem { FrameItem::ScopeBegin(Scope { owner: owner(id), - effect: ScopeEffect::Opacity(ScopeOpacity::new(0.5).expect("0.5 is a scope fact")), + effect: ScopeEffect::Opacity(ScopeOpacityGroup::new( + ScopeOpacity::new(0.5).expect("0.5 is a scope fact"), + )), }) } diff --git a/crates/rframe/tests/opacity_source_domain.rs b/crates/rframe/tests/opacity_source_domain.rs new file mode 100644 index 00000000..11064c16 --- /dev/null +++ b/crates/rframe/tests/opacity_source_domain.rs @@ -0,0 +1,337 @@ +//! Producer-only laws for ordinary isolated opacity with a complete source. +//! +//! An illustration states its source enclosure separately from its painted +//! nodes. These tests establish contract facts and refusals without a renderer. + +use std::sync::Arc; + +use cg::{CGColor, GradientStop, Paint, Paints, RadialGradientPaint}; +use math2::Rectangle; +use math2::transform::AffineTransform; +use rframe::{ + BlendSourceDomain, BlendSourceDomainError, Frame, FrameItem, FrameItems, FrameItemsError, + FrameNode, Geometry, Identity, IsolatedSourceDomain, IsolatedSourceDomainError, + MAX_PATTERN_DEPTH, MAX_SCOPE_DEPTH, PaintStack, PatternPaint, PatternPaintError, Provenance, + Scope, ScopeBlend, ScopeBlendMode, ScopeEffect, ScopeOpacity, ScopeOpacityGroup, VisualRef, +}; + +fn owner(id: u64) -> VisualRef { + VisualRef::new(Identity::new(id), Provenance::new(id + 100)) +} + +fn domain() -> IsolatedSourceDomain { + IsolatedSourceDomain::new( + Rectangle::from_xywh(-4.0, -2.0, 48.0, 40.0), + AffineTransform::from_acebdf(1.0, 0.25, 8.0, 0.0, 1.0, 6.0), + ) + .unwrap() +} + +fn group(source: Option) -> ScopeOpacityGroup { + let group = ScopeOpacityGroup::new(ScopeOpacity::new(0.375).unwrap()); + match source { + Some(source) => group.with_source_domain(source), + None => group, + } +} + +fn begin(id: u64, group: ScopeOpacityGroup) -> FrameItem { + FrameItem::ScopeBegin(Scope { + owner: owner(id), + effect: ScopeEffect::Opacity(group), + }) +} + +fn node(id: u64, paints: PaintStack) -> FrameItem { + let bounds = Rectangle::from_xywh(4.0, 6.0, 20.0, 16.0); + FrameItem::Node(FrameNode { + owner: owner(id), + transform: AffineTransform::from_acebdf(1.0, 0.0, 12.0, 0.0, 1.0, 9.0), + geometry: Geometry::Rect(bounds), + bounds, + paints, + stroke: None, + }) +} + +fn illustration(source: Option) -> Frame { + let ramp = + PaintStack::try_from_paints(Paints::new([Paint::RadialGradient(RadialGradientPaint { + stops: vec![ + GradientStop { + offset: 0.0, + color: CGColor::RED.into(), + }, + GradientStop { + offset: 1.0, + color: CGColor::BLUE.into(), + }, + ], + ..Default::default() + })])) + .unwrap(); + let mut ellipse = node(3, ramp); + let FrameItem::Node(ref mut ellipse_node) = ellipse else { + unreachable!() + }; + ellipse_node.geometry = Geometry::Ellipse(ellipse_node.bounds); + Frame { + owner: owner(1), + bounds: Rectangle::from_xywh(0.0, 0.0, 80.0, 64.0), + items: FrameItems::try_new(vec![ + begin(2, group(source)), + ellipse, + node(4, PaintStack::solid(CGColor::from_rgba(40, 80, 160, 128))), + FrameItem::ScopeEnd, + ]) + .unwrap(), + } +} + +/// The source declaration belongs to the group, while the exact numeric factor +/// remains usable independently by a combined blend operation. +#[test] +fn absence_and_attachment_preserve_the_exact_numeric_factor() { + for value in [ + f32::from_bits(1), + f32::MIN_POSITIVE, + 0.375, + 1.0_f32.next_down(), + ] { + let factor = ScopeOpacity::new(value).unwrap(); + let ordinary = ScopeOpacityGroup::new(factor); + let declared = ordinary.with_source_domain(domain()); + assert_eq!(ordinary.source_domain(), None); + assert_eq!(declared.source_domain(), Some(domain())); + assert_eq!(ordinary.opacity().get().to_bits(), value.to_bits()); + assert_eq!(declared.opacity().get().to_bits(), value.to_bits()); + assert_ne!(ordinary, declared); + let blend = ScopeBlend::new(ScopeBlendMode::Multiply, Some(declared.opacity())); + assert_eq!(blend.opacity(), Some(factor)); + assert_eq!(blend.source_domain(), None); + } +} + +/// Transparent extent is independent of geometry and ramp placement. Attaching +/// it preserves one ordinary opacity boundary, its identity, and its children. +#[test] +fn an_independent_illustration_preserves_paint_geometry_owners_and_transforms() { + let ordinary = illustration(None); + let declared = illustration(Some(domain())); + let wider = illustration(Some( + IsolatedSourceDomain::new( + Rectangle::from_xywh(-8.0, -6.0, 56.0, 48.0), + domain().source_to_stream(), + ) + .unwrap(), + )); + for frame in [&declared, &wider] { + assert_eq!(frame.owner, ordinary.owner); + assert_eq!(frame.bounds, ordinary.bounds); + assert_eq!(frame.nodes(), ordinary.nodes()); + assert_eq!(frame.items.len(), 4); + let FrameItem::ScopeBegin(scope) = frame.items.iter().next().unwrap() else { + panic!("the ordinary opacity boundary must survive"); + }; + assert_eq!(scope.owner, owner(2)); + let ScopeEffect::Opacity(group) = scope.effect else { + panic!("a source declaration must not become a different effect"); + }; + assert_eq!(group.opacity(), ScopeOpacity::new(0.375).unwrap()); + assert!(group.source_domain().is_some()); + assert_ne!(frame, &ordinary); + } + assert_ne!(declared, wider); + assert_eq!(declared, illustration(Some(domain()))); +} + +/// Source-local units and placement are exact resolved facts, including signed +/// zero. Attaching a declaration must not canonicalize either into the other. +#[test] +fn attachment_preserves_rectangle_and_map_bits() { + let rect = Rectangle::from_xywh(-0.0, -0.5, 10.25, 8.75); + let map = AffineTransform::from_acebdf(-1.0, 0.25, 25.5, -0.0, 2.0, -0.25); + let declared = group(Some(IsolatedSourceDomain::new(rect, map).unwrap())); + let source = declared.source_domain().unwrap(); + let stored = source.rect(); + assert_eq!( + [stored.x, stored.y, stored.width, stored.height].map(f32::to_bits), + [rect.x, rect.y, rect.width, rect.height].map(f32::to_bits), + ); + assert_eq!( + source + .source_to_stream() + .matrix + .map(|row| row.map(f32::to_bits)), + map.matrix.map(|row| row.map(f32::to_bits)), + ); +} + +/// Equal mapped enclosures cannot erase distinct source-local declarations. +#[test] +fn equal_mapped_boxes_do_not_identify_opacity_groups() { + let first = IsolatedSourceDomain::new( + Rectangle::from_xywh(0.0, 0.0, 4.0, 2.0), + AffineTransform::identity(), + ) + .unwrap(); + let second = IsolatedSourceDomain::new( + Rectangle::from_xywh(0.0, 0.0, 2.0, 4.0), + AffineTransform::from_acebdf(0.0, 1.0, 0.0, 1.0, 0.0, 0.0), + ) + .unwrap(); + assert_eq!( + math2::rect_transform(first.rect(), &first.source_to_stream()), + math2::rect_transform(second.rect(), &second.source_to_stream()), + ); + assert_ne!(group(Some(first)), group(Some(second))); +} + +/// Opacity and blend share one numerical gate. The compatibility names retain +/// the same checked type and typed refusals, not a second validation path. +#[test] +fn opacity_and_blend_share_the_domain_and_its_numerical_refusals() { + use rframe::BlendSourceDomainError::InvalidRectangle; + + assert_eq!( + InvalidRectangle, + IsolatedSourceDomainError::InvalidRectangle + ); + let legacy: BlendSourceDomain = domain(); + let blend = ScopeBlend::new(ScopeBlendMode::Screen, Some(group(None).opacity())) + .with_source_domain(legacy); + assert_eq!(blend.source_domain(), group(Some(domain())).source_domain()); + for (rect, map, error) in [ + ( + Rectangle::from_xywh(0.0, 0.0, 0.0, 1.0), + AffineTransform::identity(), + IsolatedSourceDomainError::InvalidRectangle, + ), + ( + Rectangle::from_xywh(f32::MAX, 0.0, f32::MAX, 1.0), + AffineTransform::identity(), + IsolatedSourceDomainError::InvalidRectangle, + ), + ( + domain().rect(), + AffineTransform::from_acebdf(1.0, 2.0, 0.0, 2.0, 4.0, 0.0), + IsolatedSourceDomainError::InvalidTransform, + ), + ( + domain().rect(), + AffineTransform::from_acebdf(1.0, 0.0, f32::INFINITY, 0.0, 1.0, 0.0), + IsolatedSourceDomainError::InvalidTransform, + ), + ( + domain().rect(), + AffineTransform::from_acebdf(1.0, 0.0, 1e20, 0.0, 1.0, 1e20), + IsolatedSourceDomainError::InvalidMappedBounds, + ), + ] { + let legacy_error: BlendSourceDomainError = error; + assert_eq!(IsolatedSourceDomain::new(rect, map), Err(error)); + assert_eq!(BlendSourceDomain::new(rect, map), Err(legacy_error)); + } +} + +/// Each boundary owns its completed source. Mixed nesting cannot transfer a +/// domain to its parent, add a synthetic opacity scope, or rebase child nodes. +#[test] +fn nested_opacity_and_blend_sources_remain_independent_in_stream_order() { + let outer = group(Some(domain())); + let inner_domain = IsolatedSourceDomain::new( + Rectangle::from_xywh(0.0, 0.0, 16.0, 12.0), + AffineTransform::from_acebdf(2.0, 0.0, 16.0, 0.0, 2.0, 12.0), + ) + .unwrap(); + for inner in [ + ScopeEffect::Opacity(group(Some(inner_domain))), + ScopeEffect::Blend( + ScopeBlend::new(ScopeBlendMode::Multiply, Some(outer.opacity())) + .with_source_domain(inner_domain), + ), + ] { + let items = vec![ + begin(1, outer), + FrameItem::ScopeBegin(Scope { + owner: owner(2), + effect: inner, + }), + node(3, PaintStack::solid(CGColor::RED)), + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ]; + let checked = FrameItems::try_new(items.clone()).unwrap(); + assert_eq!(checked.iter().cloned().collect::>(), items); + assert_eq!(checked.len(), 5); + assert_eq!(checked.nodes().count(), 1); + } +} + +/// A domain supplies no content and no extra structural depth allowance. +#[test] +fn source_declarations_preserve_scope_content_balance_and_depth_refusals() { + let declared = group(Some(domain())); + assert_eq!( + FrameItems::try_new(vec![begin(1, declared), FrameItem::ScopeEnd]), + Err(FrameItemsError::EmptyScope { index: 0 }), + ); + assert_eq!( + FrameItems::try_new(vec![ + begin(1, declared), + node(2, PaintStack::solid(CGColor::RED)) + ]), + Err(FrameItemsError::UnclosedScope { index: 0 }), + ); + let mut maximum = (0..MAX_SCOPE_DEPTH) + .map(|id| begin(id as u64, declared)) + .collect::>(); + let mut overflow = maximum.clone(); + overflow.push(begin(100, declared)); + assert_eq!( + FrameItems::try_new(overflow), + Err(FrameItemsError::ScopeTooDeep { + index: MAX_SCOPE_DEPTH, + }) + ); + maximum.push(node(100, PaintStack::solid(CGColor::RED))); + maximum.extend(std::iter::repeat_n(FrameItem::ScopeEnd, MAX_SCOPE_DEPTH)); + FrameItems::try_new(maximum).unwrap(); +} + +fn repeating(paints: PaintStack) -> Result { + let declared = group(Some(domain())); + let items = Arc::new( + FrameItems::try_new(vec![ + begin(1, declared), + node(2, paints), + FrameItem::ScopeEnd, + ]) + .unwrap(), + ); + let pattern = PatternPaint::new( + 64.0, + 64.0, + AffineTransform::from_acebdf(3.0, 0.0, 12.0, 0.0, 2.0, 16.0), + Arc::clone(&items), + 1.0, + )?; + assert!(Arc::ptr_eq(pattern.items(), &items)); + assert_eq!(pattern.items().iter().next(), Some(&begin(1, declared))); + Ok(pattern) +} + +/// A repeating program's placement must not rewrite its source-to-tile map. +/// Opacity declarations also cannot hide recursive paint programs from checks. +#[test] +fn repeating_programs_preserve_tile_local_sources_and_the_recursion_bound() { + let mut pattern = repeating(PaintStack::solid(CGColor::RED)).unwrap(); + for expected in 2..=MAX_PATTERN_DEPTH { + pattern = repeating(PaintStack::from_pattern(pattern)).unwrap(); + assert_eq!(pattern.depth(), expected); + } + assert_eq!( + repeating(PaintStack::from_pattern(pattern)), + Err(PatternPaintError::TooDeep) + ); +} diff --git a/crates/rframe/tests/scope_stream.rs b/crates/rframe/tests/scope_stream.rs index 354ecf9b..48b97e42 100644 --- a/crates/rframe/tests/scope_stream.rs +++ b/crates/rframe/tests/scope_stream.rs @@ -12,7 +12,8 @@ use math2::transform::AffineTransform; use rframe::{ Filter, FilterColorSpace, FilterInput, FilterNode, FilterPrimitive, FilterProgram, FilterTurbulenceKind, FrameItem, FrameItems, FrameItemsError, FrameNode, Geometry, Identity, - MAX_SCOPE_DEPTH, PaintStack, Provenance, Scope, ScopeEffect, ScopeOpacity, VisualRef, + MAX_SCOPE_DEPTH, PaintStack, Provenance, Scope, ScopeEffect, ScopeOpacity, ScopeOpacityGroup, + VisualRef, }; use std::sync::Arc; @@ -35,7 +36,9 @@ fn node(id: u64) -> FrameItem { fn begin(id: u64) -> FrameItem { FrameItem::ScopeBegin(Scope { owner: owner(id), - effect: ScopeEffect::Opacity(ScopeOpacity::new(0.5).expect("0.5 is a scope fact")), + effect: ScopeEffect::Opacity(ScopeOpacityGroup::new( + ScopeOpacity::new(0.5).expect("0.5 is a scope fact"), + )), }) } diff --git a/crates/websem/src/svg.rs b/crates/websem/src/svg.rs index 0e0dee5f..1cc574a7 100644 --- a/crates/websem/src/svg.rs +++ b/crates/websem/src/svg.rs @@ -3456,8 +3456,17 @@ fn compile_svg_element( items.insert(0, blend_scope_item(&mut next_id, composite)); items.push(FrameItem::ScopeEnd); } else if root_patrol.opacity < 1.0 && !items.is_empty() { - items.insert(0, scope_item(&mut next_id, root_patrol.opacity)); + let scope = opacity_source_scope_item( + &mut next_id, + root_patrol.opacity, + &items, + viewport, + root_facts, + &[], + )?; + items.insert(0, scope); items.push(FrameItem::ScopeEnd); + root_facts.has_opacity_source_paint = false; } // A standalone SVG's initial backdrop is transparent, not the arbitrary // destination the eventual Frame consumer supplies. Resolve that source @@ -3477,8 +3486,17 @@ fn compile_svg_element( } for opacity in host_opacities.iter().rev() { if !items.is_empty() { - items.insert(0, scope_item(&mut next_id, *opacity)); + let scope = opacity_source_scope_item( + &mut next_id, + *opacity, + &items, + viewport, + root_facts, + &[], + )?; + items.insert(0, scope); items.push(FrameItem::ScopeEnd); + root_facts.has_opacity_source_paint = false; } } @@ -3560,6 +3578,14 @@ struct SpanFacts { source_domain_bounds: Option, source_domain_incomplete: bool, needs_source_domain: bool, + /// An authored contributor was resolved away without a complete drawable + /// enclosure. It must not disappear from a later opacity source declaration. + opacity_source_incomplete: bool, + /// Bare ramp/pattern draws, consumed when their image is completed. This + /// bottom-up fact keeps neutral/solid opacity spans off the enclosure walk. + has_opacity_source_paint: bool, + /// A descendant's checked opacity enclosure remains in the item stream. + has_opacity_source_domain: bool, } impl SpanFacts { @@ -3569,6 +3595,7 @@ impl SpanFacts { fn filter_hidden() -> Self { Self { linear_source_boundary: Some("with a filter-hidden source contributor"), + opacity_source_incomplete: true, ..Self::default() } } @@ -3594,6 +3621,9 @@ impl SpanFacts { }; self.source_domain_incomplete |= other.source_domain_incomplete; self.needs_source_domain |= other.needs_source_domain; + self.opacity_source_incomplete |= other.opacity_source_incomplete; + self.has_opacity_source_paint |= other.has_opacity_source_paint; + self.has_opacity_source_domain |= other.has_opacity_source_domain; } } @@ -5192,6 +5222,39 @@ struct PaintContext<'d> { } impl<'a> ChildWalk<'a> { + fn opacity_scope( + &mut self, + start: usize, + opacity: f32, + source_to_stream: AffineTransform, + facts: &mut SpanFacts, + ) -> Result { + if facts.has_opacity_source_paint + && (!self.active_patterns.is_empty() + || !self.active_masks.is_empty() + || !self.active_markers.is_empty()) + { + return Err(opacity_source_refusal( + "needs its mask, pattern, or marker source-program profile", + )); + } + let scope = opacity_source_scope_item( + &mut self.next_id, + opacity, + &self.items[start..], + source_to_stream, + *facts, + &self.elided_blends, + )?; + if let FrameItem::ScopeBegin(scope) = &scope + && matches!(scope.effect, ScopeEffect::Opacity(group) if group.source_domain().is_some()) + { + facts.has_opacity_source_domain = true; + } + facts.has_opacity_source_paint = false; + Ok(scope) + } + fn reset_next_id(&mut self, next_id: u64) { self.next_id = next_id; self.elided_blends.truncate(next_id as usize); @@ -5295,7 +5358,13 @@ impl<'a> ChildWalk<'a> { checkpoint: usize, mut facts: SpanFacts, mut filter: Filter, - ) -> SpanFacts { + ) -> Result { + if facts.has_opacity_source_domain { + return Err(opacity_source_refusal( + "needs its enclosing filter or mask profile", + )); + } + patrol_image_source_extent(facts)?; // Effect participation survives command elision: an invisible input // can still change the enclosure used by an enclosing gradient blend. if facts.has_geometry { @@ -5305,7 +5374,7 @@ impl<'a> ChildWalk<'a> { } if facts.draws == 0 && !facts.has_scope { if !filter.program().may_paint_transparent_input() { - return facts; + return Ok(facts); } filter = filter.with_transparent_source(); } @@ -5317,10 +5386,11 @@ impl<'a> ChildWalk<'a> { opacity_passes: 0, has_scope: true, has_image_effect: true, + has_opacity_source_paint: false, escaping_blend: false, ..facts }; - facts + Ok(facts) } /// Wrap a completed target span in a resolved image mask, then compile the @@ -5338,6 +5408,12 @@ impl<'a> ChildWalk<'a> { let Some(invocation) = invocation else { return Ok(facts); }; + if facts.has_opacity_source_domain { + return Err(opacity_source_refusal( + "needs its enclosing filter or mask profile", + )); + } + patrol_image_source_extent(facts)?; if facts.has_geometry { facts.linear_source_boundary = facts .linear_source_boundary @@ -5413,6 +5489,7 @@ impl<'a> ChildWalk<'a> { opacity_passes: 0, has_scope: true, has_image_effect: true, + has_opacity_source_paint: false, escaping_blend: false, ..facts }; @@ -5427,9 +5504,18 @@ impl<'a> ChildWalk<'a> { checkpoint: usize, mut facts: SpanFacts, opacity: f32, - ) -> SpanFacts { + ) -> Result { let has_subject = facts.has_geometry || facts.opacity_passes > 0 || facts.has_scope || facts.has_opacity; + // Filters and masks consume the bare-source fact when they finish + // their image. A marker client still has separate fill/stroke/marker + // operations here; treating those as a completed image loses its + // paint-server source enclosure. + if opacity > 0.0 && opacity < 1.0 && has_subject && facts.has_opacity_source_paint { + return Err(opacity_source_refusal( + "needs its marker-client source profile", + )); + } if opacity == 0.0 && has_subject { self.items.truncate(checkpoint); facts = SpanFacts { @@ -5454,7 +5540,7 @@ impl<'a> ChildWalk<'a> { ..facts }; } - facts + Ok(facts) } /// Wrap a completed target span in its geometric clip. The insertion point @@ -5791,6 +5877,7 @@ impl<'a> ChildWalk<'a> { // The parent composites a completed image. Do not confuse // it with a ramp rasterized directly into the parent. facts.has_linear_source = false; + facts.has_opacity_source_paint = false; facts.linear_source_boundary = None; facts.needs_source_domain = false; } @@ -5872,10 +5959,10 @@ impl<'a> ChildWalk<'a> { self.compile_children(el, transform, bases, path, depth + 1, replay_opacity); facts.and_then(|mut facts| { if let Some(filter) = filter { - facts = self.wrap_span_with_filter(checkpoint, facts, filter); + facts = self.wrap_span_with_filter(checkpoint, facts, filter)?; } facts = self.wrap_span_with_mask(checkpoint, facts, mask, path, depth)?; - Ok(self.wrap_masked_span_with_opacity(checkpoint, facts, patrol.opacity)) + self.wrap_masked_span_with_opacity(checkpoint, facts, patrol.opacity) }) } else { self.compile_span_with_opacity( @@ -5986,10 +6073,10 @@ impl<'a> ChildWalk<'a> { facts.and_then(|mut facts| { facts = self.wrap_span_with_clip(checkpoint, facts, viewport_clip); if let Some(filter) = filter { - facts = self.wrap_span_with_filter(checkpoint, facts, filter); + facts = self.wrap_span_with_filter(checkpoint, facts, filter)?; } facts = self.wrap_span_with_mask(checkpoint, facts, mask, path, depth)?; - Ok(self.wrap_masked_span_with_opacity(checkpoint, facts, patrol.opacity)) + self.wrap_masked_span_with_opacity(checkpoint, facts, patrol.opacity) }) } else { self.compile_span_with_opacity( @@ -6083,7 +6170,8 @@ impl<'a> ChildWalk<'a> { } else { let materialized = facts.draws > 0 || facts.has_scope; if materialized { - let scope = scope_item(&mut self.next_id, own_opacity); + let scope = + self.opacity_scope(checkpoint.0, own_opacity, transform, &mut facts)?; self.items.insert(checkpoint.0, scope); self.items.push(FrameItem::ScopeEnd); } @@ -6219,10 +6307,10 @@ impl<'a> ChildWalk<'a> { self.compile_children(el, transform, bases, path, depth + 1, replay_opacity); facts.and_then(|mut facts| { if let Some(filter) = filter { - facts = self.wrap_span_with_filter(checkpoint, facts, filter); + facts = self.wrap_span_with_filter(checkpoint, facts, filter)?; } facts = self.wrap_span_with_mask(checkpoint, facts, mask, path, depth)?; - Ok(self.wrap_masked_span_with_opacity(checkpoint, facts, patrol.opacity)) + self.wrap_masked_span_with_opacity(checkpoint, facts, patrol.opacity) }) } else { self.compile_span_with_opacity( @@ -6645,6 +6733,10 @@ impl<'a> ChildWalk<'a> { marker_positions, } = compilation; if let Some(outcome) = outcome.as_ref() { + facts.has_opacity_source_paint = outcome.nodes.iter().any(opacity_source_paint); + facts.opacity_source_incomplete = outcome.unresolved_fill_source_extent + || outcome.omitted_stroke_extent + || (outcome.has_geometry && outcome.draws == 0 && outcome.opacity_passes > 0); facts.draws = outcome.draws; facts.opacity_passes = outcome.opacity_passes; facts.has_opacity = outcome.has_opacity; @@ -6783,11 +6875,16 @@ impl<'a> ChildWalk<'a> { // The shape's fill and stroke composite together // through one isolated layer. The default-order route // deliberately retains the established one-node fact. - let scope = scope_item(&mut self.next_id, opacity); - self.items.push(scope); + let source_start = self.items.len(); debug_assert!(!outcome.nodes.is_empty()); self.items .extend(outcome.nodes.into_iter().map(FrameItem::Node)); + let own_has_opacity = facts.has_opacity; + facts.has_opacity = false; + let scope = + self.opacity_scope(source_start, opacity, target_to_frame, &mut facts)?; + facts.has_opacity = own_has_opacity; + self.items.insert(source_start, scope); self.items.push(FrameItem::ScopeEnd); facts.draws = 0; facts.opacity_passes = 0; @@ -6801,11 +6898,9 @@ impl<'a> ChildWalk<'a> { self.items.append(&mut marker_items); } else if let Some(outcome) = outcome { let scope_opacity = outcome.scope_opacity; + let source_start = self.items.len(); let (mut fill_items, mut stroke_items, mut paintless_items) = split_shape_paint_channels(outcome.nodes, &mut self.next_id); - if let Some(opacity) = scope_opacity { - self.items.push(scope_item(&mut self.next_id, opacity)); - } // Paintless geometry remains in the frame for the same downstream // box/context consumers as before, but it has no painter-order // relation of its own. @@ -6817,7 +6912,13 @@ impl<'a> ChildWalk<'a> { SvgPaintOperation::Markers => self.items.append(&mut marker_items), } } - if scope_opacity.is_some() { + if let Some(opacity) = scope_opacity { + let own_has_opacity = facts.has_opacity; + facts.has_opacity = false; + let scope = + self.opacity_scope(source_start, opacity, target_to_frame, &mut facts)?; + facts.has_opacity = own_has_opacity; + self.items.insert(source_start, scope); self.items.push(FrameItem::ScopeEnd); facts.draws = 0; facts.opacity_passes = 0; @@ -6830,10 +6931,10 @@ impl<'a> ChildWalk<'a> { } facts.absorb(marker_facts); if let Some(filter) = filter { - facts = self.wrap_span_with_filter(checkpoint, facts, filter); + facts = self.wrap_span_with_filter(checkpoint, facts, filter)?; } facts = self.wrap_span_with_mask(checkpoint, facts, mask, path, depth)?; - facts = self.wrap_masked_span_with_opacity(checkpoint, facts, deferred_opacity); + facts = self.wrap_masked_span_with_opacity(checkpoint, facts, deferred_opacity)?; facts = self.wrap_span_with_clip(checkpoint, facts, clip); if top_level { self.top_level_shapes.push(el.node_id()); @@ -6849,12 +6950,187 @@ fn scope_item(next_id: &mut u64, opacity: f32) -> FrameItem { *next_id += 1; FrameItem::ScopeBegin(Scope { owner: VisualRef::new(Identity::new(scope_id), Provenance::new(scope_id)), - effect: ScopeEffect::Opacity( + effect: ScopeEffect::Opacity(rframe::ScopeOpacityGroup::new( ScopeOpacity::new(opacity).expect("a computed opacity strictly inside (0, 1)"), - ), + )), }) } +fn opacity_source_refusal(reason: &str) -> CompileError { + CompileError::UnsupportedStyle(format!("opacity source-extent {reason}")) +} + +/// Multiple bare passes (or a bare server beside a completed child image) +/// expose the same missing source enclosure without any element opacity. +/// Their filter/mask source materialization remains a separate guarded profile. +fn patrol_image_source_extent(facts: SpanFacts) -> Result<(), CompileError> { + if facts.has_opacity_source_paint && (facts.opacity_passes > 1 || facts.has_scope) { + return Err(CompileError::UnsupportedStyle( + "paint-server source-extent needs its multi-operation filter or mask profile" + .to_string(), + )); + } + Ok(()) +} + +fn opacity_source_paint(node: &FrameNode) -> bool { + std::iter::once(&node.paints) + .chain(node.stroke.iter().map(Stroke::paints)) + .any(|paints| { + paints.pattern().is_some() + || paints.iter().any(|paint| { + matches!( + paint, + cg::Paint::LinearGradient(_) | cg::Paint::RadialGradient(_) + ) + }) + }) +} + +/// Resolve the complete source of a bare paint-server opacity group in the +/// group's own coordinate space. Local drawable enclosure precedes the group's +/// map. This is not the geometry inventory, clipped ink, or a damage envelope. +/// Completed child images are not reclassified as bare ramp/pattern draws. +fn opacity_source_scope_item( + next_id: &mut u64, + opacity: f32, + items: &[FrameItem], + source_to_stream: AffineTransform, + facts: SpanFacts, + elided_blends: &[bool], +) -> Result { + if !facts.has_opacity_source_paint { + return Ok(scope_item(next_id, opacity)); + } + if facts.has_scope || facts.has_opacity || facts.opacity_source_incomplete { + return Err(opacity_source_refusal( + "needs the non-nested, completely represented source profile", + )); + } + // Rotation/reflection plus uniform scale preserves this source profile. + // Shear has an opacity-only precision departure; unequal scales also have + // an independent no-opacity departure. Do not silently extend the profile. + let [[a, c, _], [b, d, _]] = source_to_stream.matrix.map(|row| row.map(f64::from)); + if a * c + b * d != 0.0 || a * a + b * b != c * c + d * d { + return Err(opacity_source_refusal( + "needs its shear or unequal-scale source mapping profile", + )); + } + let mut bounds: Option = None; + let mut elided_depth = 0; + for item in items { + match item { + FrameItem::ScopeBegin(scope) + if elided_blends + .get(scope.owner.identity().get() as usize - 1) + .copied() + .unwrap_or(false) => + { + elided_depth += 1; + continue; + } + FrameItem::ScopeEnd if elided_depth > 0 => { + elided_depth -= 1; + continue; + } + _ => {} + } + let FrameItem::Node(node) = item else { + return Err(opacity_source_refusal( + "cannot include an unresolved source operation", + )); + }; + let rect = match node.geometry { + Geometry::Rect(rect) | Geometry::Ellipse(rect) => rect, + Geometry::Path(_) => { + return Err(opacity_source_refusal( + "needs its path-source enclosure profile", + )); + } + }; + if node.transform != source_to_stream { + return Err(opacity_source_refusal( + "needs its independently transformed contributor profile", + )); + } + // A retained disabled box has no fill or stroke coverage. Enclosing + // its fractional zero extent would fabricate a nonempty source cell. + // Keep its geometry/identity, but do not make it source membership. + if (rect.width == 0.0 || rect.height == 0.0) && node.stroke.is_none() { + continue; + } + if node.paints.is_empty() && node.stroke.is_none() { + continue; + } + let width = if let Some(stroke) = &node.stroke { + if stroke.space() != StrokeSpace::Local + || stroke.cap() != StrokeCap::Butt + || stroke.join() != StrokeJoin::Miter + || stroke.dash().is_some() + || stroke.dash_intervals().is_some() + { + return Err(opacity_source_refusal( + "needs its complex-stroke enclosure profile", + )); + } + stroke.width() + } else { + 0.0 + }; + let half = width / 2.0; + let x = rect.x - half; + let y = rect.y - half; + let right = x + (rect.width + width); + let bottom = y + (rect.height + width); + if [x, y, right, bottom] + .into_iter() + .any(|v| !v.is_finite() || v.abs() > 8_388_608.0) + { + return Err(opacity_source_refusal( + "has an unrepresentable source enclosure", + )); + } + let (x, y, right, bottom) = (x.floor(), y.floor(), right.ceil(), bottom.ceil()); + let exact_half = f64::from(width) / 2.0; + if f64::from(rect.x) - exact_half < f64::from(x) + || f64::from(rect.y) - exact_half < f64::from(y) + || f64::from(rect.x) + f64::from(rect.width) + exact_half > f64::from(right) + || f64::from(rect.y) + f64::from(rect.height) + exact_half > f64::from(bottom) + { + return Err(opacity_source_refusal( + "has an inward-rounded source enclosure", + )); + } + let next = Rectangle::from_xywh(x, y, right - x, bottom - y); + bounds = Some(bounds.map_or(next, |old| math2::union(&[old, next]))); + } + let domain = rframe::IsolatedSourceDomain::new( + bounds.ok_or_else(|| opacity_source_refusal("has no complete source enclosure"))?, + source_to_stream, + ) + .map_err(|_| opacity_source_refusal("has an unrepresentable source mapping"))?; + let mapped = math2::rect_transform(domain.rect(), &source_to_stream); + if mapped + .corners() + .into_iter() + .flatten() + .any(|v| v.abs() > 8_388_608.0) + { + return Err(opacity_source_refusal( + "has an out-of-range mapped source enclosure", + )); + } + let mut item = scope_item(next_id, opacity); + let FrameItem::ScopeBegin(scope) = &mut item else { + unreachable!() + }; + let ScopeEffect::Opacity(group) = scope.effect else { + unreachable!() + }; + scope.effect = ScopeEffect::Opacity(group.with_source_domain(domain)); + Ok(item) +} + fn blend_scope_item(next_id: &mut u64, composite: ScopeBlend) -> FrameItem { *next_id += 1; FrameItem::ScopeBegin(Scope { diff --git a/crates/websem/tests/opacity_contract.rs b/crates/websem/tests/opacity_contract.rs index 9d949306..9024a5a0 100644 --- a/crates/websem/tests/opacity_contract.rs +++ b/crates/websem/tests/opacity_contract.rs @@ -95,7 +95,7 @@ fn scope_opacities(frame: &rframe::Frame) -> Vec { let ScopeEffect::Opacity(opacity) = scope.effect else { panic!("opacity fixture emitted a non-opacity scope"); }; - Some(opacity.get()) + Some(opacity.opacity().get()) } _ => None, }) diff --git a/crates/websem/tests/opacity_source_domain.rs b/crates/websem/tests/opacity_source_domain.rs new file mode 100644 index 00000000..1b487cc5 --- /dev/null +++ b/crates/websem/tests/opacity_source_domain.rs @@ -0,0 +1,206 @@ +//! SVG producer laws for ordinary opacity's complete drawable source. +//! Pixel claims live in the externally baked corpus, not these shape checks. + +use math2::Rectangle; +use rframe::{FrameItem, ScopeEffect}; +use websem::{InitialViewport, SvgFrameSource}; + +fn svg(body: &str) -> String { + format!( + r##"{body}"## + ) +} + +fn source() -> &'static str { + r##""## +} + +fn both(input: &str) -> rframe::Frame { + let viewport = InitialViewport::new(64.0, 64.0); + let strict = SvgFrameSource::from_standalone_svg(input, viewport).unwrap(); + let best = SvgFrameSource::from_standalone_svg_best_effort(input, viewport).unwrap(); + assert!(best.degradations().is_empty()); + assert_eq!(strict.base_frame(), best.base_frame()); + strict.base_frame() +} + +fn domains(frame: &rframe::Frame) -> Vec { + frame + .items + .iter() + .filter_map(|item| match item { + FrameItem::ScopeBegin(scope) => match scope.effect { + ScopeEffect::Opacity(group) => group.source_domain(), + _ => None, + }, + _ => None, + }) + .collect() +} + +#[test] +fn opacity_keeps_local_drawable_enclosure_separate_from_mapping_and_geometry() { + for alpha in [".57", ".998", ".999"] { + for map in [ + "translate(0 0)", + "translate(1.25 2.5)", + "rotate(15 32 32)", + "matrix(-1 0 0 1 64 0)", + ] { + let frame = both(&svg(&format!( + r##"{}"##, + source() + ))); + let declarations = domains(&frame); + assert_eq!(declarations.len(), 1); + assert_eq!( + declarations[0].rect(), + Rectangle::from_xywh(6.0, 6.0, 50.0, 48.0) + ); + for node in frame.nodes() { + assert_eq!(node.transform, declarations[0].source_to_stream()); + } + assert_eq!( + frame.nodes()[0].geometry.local_box(), + Rectangle::from_xywh(6.0, 6.0, 48.0, 48.0) + ); + } + } +} + +#[test] +fn plain_solid_and_one_pass_sources_do_not_gain_enclosure_facts() { + let solid = source().replace("url(#r)", "red"); + assert!(domains(&both(&svg(&format!(r##"{solid}"##)))).is_empty()); + assert!( + domains(&both(&svg( + r##""## + ))) + .is_empty() + ); +} + +#[test] +fn disabled_box_nodes_keep_identity_without_enlarging_the_opacity_source() { + for disabled in [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] { + let frame = both(&svg(&format!( + r#"{}{disabled}"#, + source() + ))); + assert_eq!(frame.nodes().len(), 3, "disabled node remains: {disabled}"); + assert!(frame.nodes()[2].stroke.is_none()); + assert!(!frame.nodes()[2].paints.is_empty()); + assert_eq!( + domains(&frame)[0].rect(), + Rectangle::from_xywh(6.0, 6.0, 50.0, 48.0), + "{disabled}" + ); + } +} + +#[test] +fn incomplete_sources_skip_the_whole_owner_without_leaking_partial_children() { + let variants = [ + ( + source().replace("", + source() + ), + "completely represented", + ), + ]; + for (body, reason) in variants { + let input = svg(&format!( + r##"{body}"## + )); + let strict = + SvgFrameSource::from_standalone_svg(input.as_str(), InitialViewport::new(64.0, 64.0)) + .unwrap_err(); + assert!(strict.to_string().contains(reason), "{strict}"); + let best = SvgFrameSource::from_standalone_svg_best_effort( + input.as_str(), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + assert_eq!(best.degradations().len(), 1); + assert_eq!(best.degradations()[0].path(), "svg/g[1]"); + assert!(best.degradations()[0].reason().contains(reason)); + assert_eq!(best.base_frame().nodes().len(), 1); + assert_eq!( + best.base_frame().nodes()[0].geometry.local_box(), + Rectangle::from_xywh(60.0, 60.0, 2.0, 2.0) + ); + } +} + +#[test] +fn resource_and_marker_sources_rollback_their_complete_client() { + for (input, path, reason) in [ + ( + include_str!( + "../../../fixtures/web-first/unsupported/svg-opacity-source-marker-client.svg" + ), + "svg/path[1]", + "marker-client source profile", + ), + ( + include_str!( + "../../../fixtures/web-first/unsupported/svg-opacity-source-mask-program.svg" + ), + "svg/rect[2]", + "source-program profile", + ), + ( + include_str!( + "../../../fixtures/web-first/unsupported/svg-paint-server-source-filter.svg" + ), + "svg/g[1]", + "multi-operation filter or mask profile", + ), + ( + include_str!( + "../../../fixtures/web-first/unsupported/svg-paint-server-source-mask.svg" + ), + "svg/g[1]", + "multi-operation filter or mask profile", + ), + ] { + let viewport = InitialViewport::new(64.0, 64.0); + let strict = SvgFrameSource::from_standalone_svg(input, viewport).unwrap_err(); + assert!(strict.to_string().contains(reason), "{strict}"); + let best = SvgFrameSource::from_standalone_svg_best_effort(input, viewport).unwrap(); + assert_eq!(best.degradations().len(), 1); + assert_eq!(best.degradations()[0].path(), path); + assert!(best.degradations()[0].reason().contains(reason)); + let frame = best.base_frame(); + assert_eq!( + frame.nodes().len(), + 1, + "only the unrelated background remains" + ); + assert_eq!(frame.items.len(), 1, "no failed source scope leaks"); + assert_eq!( + frame.nodes()[0].geometry.local_box(), + Rectangle::from_xywh(0.0, 0.0, 64.0, 64.0) + ); + } +} diff --git a/crates/websem/tests/paint_order_contract.rs b/crates/websem/tests/paint_order_contract.rs index 45352377..693c879e 100644 --- a/crates/websem/tests/paint_order_contract.rs +++ b/crates/websem/tests/paint_order_contract.rs @@ -293,7 +293,7 @@ fn element_opacity_encloses_the_complete_reordered_span() { assert!(matches!( frame.items.iter().next(), Some(FrameItem::ScopeBegin(scope)) - if matches!(scope.effect, ScopeEffect::Opacity(opacity) if opacity.get() == 0.55) + if matches!(scope.effect, ScopeEffect::Opacity(opacity) if opacity.opacity().get() == 0.55) )); assert!(matches!( frame.items.iter().last(), diff --git a/crates/websem/tests/unsupported_corpus.rs b/crates/websem/tests/unsupported_corpus.rs index 4b091ef8..14f5075c 100644 --- a/crates/websem/tests/unsupported_corpus.rs +++ b/crates/websem/tests/unsupported_corpus.rs @@ -46,6 +46,91 @@ 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-paint-server-source-filter", + DeclaredByBestEffort, + "paint-server source-extent needs its multi-operation filter or mask profile", + ), + ( + "svg-paint-server-source-mask", + DeclaredByBestEffort, + "paint-server source-extent needs its multi-operation filter or mask profile", + ), + ( + "svg-opacity-source-root-path", + BothRefuse, + "opacity source-extent needs its path-source enclosure profile", + ), + ( + "svg-opacity-source-marker-client", + DeclaredByBestEffort, + "opacity source-extent needs its marker-client source profile", + ), + ( + "svg-opacity-source-mask-program", + DeclaredByBestEffort, + "opacity source-extent needs its mask, pattern, or marker source-program profile", + ), + ( + "svg-opacity-source-child-transform", + DeclaredByBestEffort, + "opacity source-extent needs its independently transformed contributor profile", + ), + ( + "svg-opacity-source-clipped-contributor", + DeclaredByBestEffort, + "opacity source-extent needs the non-nested, completely represented source profile", + ), + ( + "svg-opacity-source-complex-stroke", + DeclaredByBestEffort, + "opacity source-extent needs its complex-stroke enclosure profile", + ), + ( + "svg-opacity-source-enclosing-filter", + DeclaredByBestEffort, + "opacity source-extent needs its enclosing filter or mask profile", + ), + ( + "svg-opacity-source-enclosing-mask", + DeclaredByBestEffort, + "opacity source-extent needs its enclosing filter or mask profile", + ), + ( + "svg-opacity-source-nested-contributor", + DeclaredByBestEffort, + "opacity source-extent needs the non-nested, completely represented source profile", + ), + ( + "svg-opacity-source-omitted-stroke", + DeclaredByBestEffort, + "opacity source-extent needs the non-nested, completely represented source profile", + ), + ( + "svg-opacity-source-path", + DeclaredByBestEffort, + "opacity source-extent needs its path-source enclosure profile", + ), + ( + "svg-opacity-source-pattern-program", + DeclaredByBestEffort, + "opacity source-extent needs its mask, pattern, or marker source-program profile", + ), + ( + "svg-opacity-source-shear", + DeclaredByBestEffort, + "opacity source-extent needs its shear or unequal-scale source mapping profile", + ), + ( + "svg-opacity-source-transparent-contributor", + DeclaredByBestEffort, + "opacity source-extent needs the non-nested, completely represented source profile", + ), + ( + "svg-opacity-source-unequal-scale", + DeclaredByBestEffort, + "opacity source-extent needs its shear or unequal-scale source mapping profile", + ), ( "svg-group-blend-sibling-review-zero-pattern", DeclaredByBestEffort, diff --git a/docs/wg/consolidation/svg-engine-of-record.md b/docs/wg/consolidation/svg-engine-of-record.md index afbfcaf0..51d35d9a 100644 --- a/docs/wg/consolidation/svg-engine-of-record.md +++ b/docs/wg/consolidation/svg-engine-of-record.md @@ -119,7 +119,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,539 Chromium-baked primitive cells plus 16 sampled frames, +- **The corpus** is 1,575 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 @@ -130,7 +130,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 351 rows. + The named refusal register has 368 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 @@ -6587,3 +6587,137 @@ report: missing repeat zero keeps its empty slot instead of promoting repeat one, comparisons check for decoded pixels, and capture failures retain signal or exit-code causes even with empty stderr. Regressions exercise those branches; the renewed actual-CLI pilot remains exact after the changes. + +## 2026-09-13 — ordinary-opacity source precision + +This rung repairs the retained cases in +[gridaco/nothing#136](https://github.com/gridaco/nothing/issues/136) without +claiming complete opacity applicability. It follows the etiology ladder to a +source-materialization policy defect and a missing resolved contract fact, +not an ellipse-geometry replacement or a blanket pixel tolerance. + +On unchanged main, twice-repeated actual CLI renders in both admissions +reproduced the retained differences against the sole pinned Chromium capture: + +| Source | Group opacity | Differing pixels | Maximum channel delta | +| --- | --- | --- | --- | +| Native circle into Gaussian blur | `.999` / `.998` | 25 / 25 | 3 / 2 | +| Repeating-pattern circle plus sibling | `.999` / `.998` | 1 / 1 | 1 / 1 | +| Rotated radial circle plus sibling | `.999` / `.998` | 1,312 / 1,307 | 1 / 1 | + +Those original observations are **measured, not celled**; the reduced sources +now have separately registered exact Chromium cells. Pattern and radial +opacity-one/absent controls were exact. Blur without opacity still differed +by 30 pixels at delta 1, and explicit sRGB by 37 at delta 1. Replacing the +native circle with an arc path changed Chromium itself by 25 pixels at delta +3; changing geometry would therefore be the wrong repair. These source +reductions are now celled, including absent opacity, arc-path, sRGB and zero +blur controls, each against its own image. + +**Completed source before blur.** Active source-dependent Gaussian blur now +uses the existing one-per-source materialization policy. It does not allocate +one layer per primitive, request a source image for generated inputs, or +activate for zero blur or an unreachable blur branch. Contract tests exercise +those distinctions. This repairs the independent no-opacity native-source +defect as well as its surrounding group-opacity examples. + +**Complete local enclosure across the seam.** Blink's +[SVG drawing recorder](https://github.com/chromium/chromium/blob/149.0.7827.55/third_party/blink/renderer/core/paint/svg_model_object_painter.h) +encloses its local visual rect; its +[paint-chunk converter](https://github.com/chromium/chromium/blob/149.0.7827.55/third_party/blink/renderer/platform/graphics/compositing/paint_chunks_to_cc_layer.cc) +maps and accumulates drawable bounds into effect space. Controlled enclosure +experiments made the pattern and radial defects disappear without changing +their paint or geometry (**measured, not celled**). +The [resolved contract](../../../crates/rframe/README.md) now carries an +optional complete source-local rectangle and map on ordinary opacity groups. +The numeric opacity factor remains separate, and the prior blend-domain +compatibility names remain available. An independent contract maintainer +designed that boundary before producer and consumer adaptation. + +The domain states complete local drawable source, not visible ink, geometry, +damage, a clip, a viewport, or an allocation hint. The producer encloses local +drawables before applying their common map. The consumer validates enclosure, +scope placement and the changed-view mapping before drawing, then uses the +domain on both ordinary-opacity routes: byte-alpha-255 Normal restoration and +the lower-alpha isolated layer. Source-domain edits do not rewrite geometry. +Changed-view reuse remains byte-identical to fresh rendering. + +The 36 new positive cells cover both near-unit routes, native blur and its +controls, repeating and gradient sources, shared mapping and simple strokes, +an enclosing geometric clip, a same-document instance, transparent root +opacity and HTML-ancestor opacity, plus disabled-box source membership. +Root and host opacity use the same source helper. +The [corpus table](../../../fixtures/web-first/README.md) names each cell's role; +the [CLI record](../../../crates/n0_cli/README.md) alone defines admission. + +**Remaining combinations refuse, and both opacity rows reopen.** Seventeen +new registered witnesses guard incomplete/omitted or nested contributors, +paths and complex strokes, independent child maps, shear/unequal scale, +enclosing image effects, resource programs and combined marker clients. +Root/host source failures are document-level in both admissions; attributable +clients roll back as whole transactions. A bare multi-operation paint-server +source feeding a filter or mask also refuses, including at opacity one. +Completed child images are not reclassified as bare paint-server sources. + +The discoveries behind these additional patrols are **measured, not celled**: +radial shear differed by 97 pixels at delta 3 only with opacity; a gradient +marker client by 1,408 at delta 1; filter/mask sources without partial opacity +by 297 at delta 2 and 923 at delta 1. Removing those image effects produces +exact admitted controls. Unequal-scale ellipse controls retained the same +three differing pixels with opacity absent, so this is not a claimed opacity +cause or a repair of gridaco/nothing#88. Mapped-root and positioned-instance +explorations were exact but remain **measured, not celled**. + +The CSS property and presentation-attribute `opacity` rows reopen: these +honored in-grammar combinations have no narrower own row that excuses their +refusal, following the gridaco/nothing#81/#89/#90 split precedent. The radial +element retains its tick because the composition gap now belongs to the open +opacity row under gridaco/nothing#75/#80. No row gains a tick, no existing +oracle is replaced, and no tolerance or FLIP rule changes. + +The evidence estate is now 1,575 primitive cells and 368 named refusals; +16 sampled frames, 16 exact text cells and eight geometry witnesses are +unchanged. Six new blur controls bring the filter estate to 459 cells. +The assertion harness gains six scene-specific rendering claims and two +expected refusals, separately labelled and run through the actual CLI. +It uses the same immutable sources, exact decoded RGBA and sole Chromium +module; it adds no second oracle or conformance measure. + +**Sensitivity.** Disabling active blur source preparation makes `just gate` +fail the new absent/high/low/sRGB cells with 30/25/25/37 differing pixels; +the focused assertion suite fails both blur claims. In the initial 31-cell +set, bypassing the opacity source declaration fails nineteen cells, including the +single-pixel pattern pair and all four root/host HTML cases. The assertion +suite fails all four pattern/radial rendering claims and both named-refusal +claims under that mutation. Both source files were restored byte-for-byte; +neither experiment changes an oracle or a tolerance. + +Independent review identified disabled box nodes as a false source contributor. +At fractional coordinates, enclosing a zero width or height fabricated a +nonempty source cell. A nine-source probe confirmed that every disabled shape +leaves Chromium's base image unchanged, while the retained rectangle/ellipse +cases changed 315 engine pixels at maximum delta 1. A zero-radius circle was +already removed before this stage and remained exact. These pre-fix CLI +observations are **measured, not celled**. +The producer and consumer now keep unstroked zero-area box geometry and +identity without treating it as drawable source area; this is not a geometry +pruning or damage-policy change. The review also corrected the radial +high/low control's description to its actual maximum channel delta 1. +Five committed zero-area box cells guard the source-membership repair; +the zero-circle and authored-stroke variants remain **measured, not celled**. +Disabling only that membership exclusion makes all five new cells fail by +315 pixels at delta 1 each. Byte-for-byte restoration preserves their retained +geometry and returns their exact Chromium result. + +The renewed independent TICK/LAW audit and manual REPRO both pass with no +remaining must-fix or should-fix items. REPRO repeated six matrices containing +95 sources through fresh Chromium and 380 actual CLI executions, verified +stable identities and complete refusal-owner rollback, and renewed the full +fixture gate and STATUS. The six rendering assertions and two expected +refusals pass, as does the original three-case pilot; both reports have empty +integrity-error lists. The known unequal-scale no-opacity control remains +outside the claimed repair. Full relevant Rust tests, post-audit producer and +consumer tests including trace/reuse, formatting, strict Clippy, 40 assertion +tool-contract tests, typechecks and link/OSS checks pass. No Workflow runner +was exposed; the saved workflow was not invoked, and these independent roles +are the explicit manual fallback. diff --git a/docs/wg/consolidation/web-checklist.md b/docs/wg/consolidation/web-checklist.md index 685e9ac7..a3f12094 100644 --- a/docs/wg/consolidation/web-checklist.md +++ b/docs/wg/consolidation/web-checklist.md @@ -581,7 +581,7 @@ excluded. - [ ] `color-scheme` - [ ] `dynamic-range-limit` - [ ] `forced-color-adjust` -- [x] `opacity` +- [ ] `opacity` - [ ] `print-color-adjust` > **2026-08-31 close:** the property closes at its complete admitted @@ -597,6 +597,14 @@ excluded. > grammar, root, host, group, gradient, transform, and instance cells carry the > rest. The former line-coverage refusal graduates. +> **2026-09-13 reopen:** the numeric grammar remains admitted, but ordinary +> isolated paint-server sources need a complete drawable enclosure. The +> [source-precision repair](https://github.com/gridaco/nothing/issues/136) +> restores the retained circle/pattern/radial examples and guards the wider +> source profiles by name. Honored path and nested-source combinations remain +> refused without an independent source-composition row, so the property is +> partial under the existing full-applicability rule. + ### CSS images @@ -2274,7 +2282,7 @@ for attributes the platform ships ahead of the SVG 2 indexes. > A valid empty source always hides its target; opaque black hides in luminance > mode and reveals under `mask-type="alpha"`. -- [x] `opacity` +- [ ] `opacity` > **2026-08-31 close:** the presentation-attribute twin closes independently > through direct shape, inherited container, ``, and `` routes. Four @@ -2285,6 +2293,13 @@ for attributes the platform ships ahead of the SVG 2 indexes. > visible-draw proxy makes the gate fail by 294–840 pixels. The complete corpus > is now 1,051 primitive cells plus 16 sampled frames, and the named refusal > register has 197 rows. No new tolerance is used. + +> **2026-09-13 reopen:** this spelling shares the same isolated-source gap as +> the CSS property above. Fixing the retained examples does not establish +> complete applicability: path, nested and incompletely represented source +> combinations still refuse by `opacity source-extent`. The attribute stays +> open independently; the numeric parser and former line repair are unchanged. + - [ ] `overflow` - [x] `paint-order` diff --git a/fixtures/web-first/README.md b/fixtures/web-first/README.md index 45903ddd..a1eed1f8 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,539 primitive cells plus 16 sampled frames, those twenty-four text -witnesses, and 351 named refusal rows. Pixel cells require byte equality, except only +estate is 1,575 primitive cells plus 16 sampled frames, those twenty-four text +witnesses, and 368 named refusal rows. Pixel cells require byte equality, except only the primitive rows carrying an explicit measured tolerance block. The real-font witness grades geometry before rasterization and makes no Chromium pixel claim. @@ -35,6 +35,12 @@ separates source membership from visible paint and records the bounded profile. | File | Role | | --- | --- | +| `svg-opacity-source-disabled-{rect,rect-width,rect-height,ellipse-width,ellipse-height}.svg` | Five zero-area source-membership regressions. Fractionally placed disabled box nodes remain in the frame but cannot enlarge the opacity source; each must equal the existing radial-simple Chromium image. The pre-fix engine changed 315 pixels at maximum delta 1 despite Chromium dropping the disabled geometry (measured, not celled). | +| `svg-opacity-source-blur-{high,low,absent,arc,srgb,zero}.svg` | Six source-materialization controls: active Gaussian blur over a native circle at group opacity `.999` / `.998` and without opacity, the separately rasterized arc-path source, explicit sRGB and zero blur. All match their own Chromium image exactly; the native ellipse is not replaced by a path. | +| `svg-opacity-source-pattern-{high,low,absent,translated,mirror,fractional,stroke}.svg` | Seven repeating-paint source-enclosure cells: both near-unit restoration routes, their no-opacity control, translated/reflected and fractionally placed sources, and simple local stroke extent. | +| `svg-opacity-source-radial-{high,low,absent,translated,mirror,fractional,stroke,simple,uniform}.svg` · `svg-opacity-source-linear-simple.svg` | Ten gradient cells: complete local drawable enclosure precedes the common source map; rotated, translated, reflected, fractional and uniform-scale radial sources plus a linear source remain exact. The `high` / `low` controls cross the byte-alpha route boundary. | +| `svg-opacity-source-enclosing-clip.svg` · `svg-opacity-source-leaf-stroke.svg` · `svg-opacity-source-root-transparent.svg` · `svg-opacity-source-use.svg` | Four ownership controls: an enclosing geometric clip, leaf fill-plus-stroke opacity, transparent root opacity and a same-document direct instance. Filter/mask enclosure and unresolved resource programs are separately refused. | +| `html-opacity-source-{root,host}-{high,low}.html` | Four inline-entry cells: SVG root and HTML-ancestor opacity at both `.999` and `.998` share the same source handling without flattening the transparent outer surface. | | `svg-upstream-resvg-rect-simple-case.svg` · `svg-upstream-resvg-rect-blue-control.svg` | Assertion-harness pilot: the unchanged upstream sharp rectangle and a separate subject-fill color control, captured at a 500×500 initial viewport. Both are exact in strict and best-effort CLI renders; changing only green to blue changes 160,000 Chromium pixels at maximum channel delta 255. Imported from resvg-test-suite revision `d8e064337faf01bc5a9579187a56dbdbe3eacc72`, `tests/shapes/rect/simple-case.svg`, under [its MIT notice](./LICENSE.resvg). These are scene assertions, not a new feature grant. | | `svg-group-blend-sibling-{multiply,screen}-{transparent,fill-zero,fill-alpha,empty-server,invalid-fallback}.svg` | Ten selected-paint sibling cells: transparent color, zero fill opacity, zero color alpha, a valid stopless linear server and a missing server with transparent fallback retain source contribution without visible fill. | | `svg-group-blend-sibling-{multiply,screen}-{stroke-only,stroke-zero,stroke-thin,stroke-px,stroke-percent,stroke-dash,solid-stroke}.svg` | Fourteen decorated-rectangle cells: omitted transparent/zero-alpha solid strokes, width spellings and inert dash/join state retain local source enclosure, including beside a solid-filled sibling. | @@ -486,7 +492,9 @@ a shadow corpus. The pre-landing verification ritual is the saved `assertions-test` checks the separate assertion-tool contracts; `assertions-gate ` runs its small actual-CLI pilot -and fresh Chromium captures. The [SVG assertion tool](../../packages/grida-reftest/svg-assertions/README.md) +and fresh Chromium captures. `assertions-opacity-gate ` +runs six described blur/pattern/radial rendering claims and two named-refusal +claims from the ordinary-opacity rung. The [SVG assertion tool](../../packages/grida-reftest/svg-assertions/README.md) records each described claim, reference decision, both admissions and their diagnostics, independent exact image comparisons, and a discrete verdict. A verified refusal is not rendering support; unresolved or missing required diff --git a/fixtures/web-first/STATUS.md b/fixtures/web-first/STATUS.md index c13988d5..f877f484 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 (1539) +## Chromium-baked cells (1575) Cells are checked against their committed Chromium oracles using exact bytes unless a manifest entry declares a measured, bounded @@ -35,6 +35,10 @@ to its fixture source. No new image is committed for this view. html-inline-svg-pattern html-inline-svg-radial-start html-inline-svg-vector-effect +html-opacity-source-host-high +html-opacity-source-host-low +html-opacity-source-root-high +html-opacity-source-root-low html-webpage-mockup svg-anchor-container svg-circle-defaults-clip @@ -1009,6 +1013,38 @@ to its fixture source. No new image is committed for this view. svg-opacity-paintless-stages svg-opacity-rotated-group svg-opacity-selected-empty-paints +svg-opacity-source-blur-absent +svg-opacity-source-blur-arc +svg-opacity-source-blur-high +svg-opacity-source-blur-low +svg-opacity-source-blur-srgb +svg-opacity-source-blur-zero +svg-opacity-source-disabled-ellipse-height +svg-opacity-source-disabled-ellipse-width +svg-opacity-source-disabled-rect +svg-opacity-source-disabled-rect-height +svg-opacity-source-disabled-rect-width +svg-opacity-source-enclosing-clip +svg-opacity-source-leaf-stroke +svg-opacity-source-linear-simple +svg-opacity-source-pattern-absent +svg-opacity-source-pattern-fractional +svg-opacity-source-pattern-high +svg-opacity-source-pattern-low +svg-opacity-source-pattern-mirror +svg-opacity-source-pattern-stroke +svg-opacity-source-pattern-translated +svg-opacity-source-radial-absent +svg-opacity-source-radial-fractional +svg-opacity-source-radial-high +svg-opacity-source-radial-low +svg-opacity-source-radial-mirror +svg-opacity-source-radial-simple +svg-opacity-source-radial-stroke +svg-opacity-source-radial-translated +svg-opacity-source-radial-uniform +svg-opacity-source-root-transparent +svg-opacity-source-use svg-opacity-stroke-only-fold svg-opacity-times-fill-opacity svg-opacity-transform-below @@ -1567,7 +1603,7 @@ to its fixture source. No new image is committed for this view. svg-visibility-rule-beats-attribute svg-visibility-unhide -## The refusal register (351) +## The refusal register (368) What the slice refuses, by name, in the compiler's own words — **both refuse** is a document-level contract; **declared** renders @@ -1809,11 +1845,28 @@ its row into the cells above. | `svg-mask-type-inherit` | declared | skipped svg/rect[2]: unsupported SVG mask: mask-type presentation attribute uses inherit, whose parent computed value is not represented at this Stylo pin | | `svg-mask-type-var` | declared | skipped svg/rect[2]: unsupported SVG mask: mask-type presentation attribute uses var(), whose substitution is not represented at this Stylo pin | | `svg-mask-var` | declared | skipped svg/rect[2]: unsupported SVG mask: mask presentation attribute uses var(), whose substitution is not represented at this Stylo pin | +| `svg-opacity-source-child-transform` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs its independently transformed contributor profile | +| `svg-opacity-source-clipped-contributor` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs the non-nested, completely represented source profile | +| `svg-opacity-source-complex-stroke` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs its complex-stroke enclosure profile | +| `svg-opacity-source-enclosing-filter` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs its enclosing filter or mask profile | +| `svg-opacity-source-enclosing-mask` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs its enclosing filter or mask profile | +| `svg-opacity-source-marker-client` | declared | skipped svg/path[1]: unsupported computed style: opacity source-extent needs its marker-client source profile | +| `svg-opacity-source-mask-program` | declared | skipped svg/rect[2]: unsupported SVG mask: mask source cannot be compiled completely: unsupported computed style: opacity source-extent needs its mask, pattern, or marker source-program profile | +| `svg-opacity-source-nested-contributor` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs the non-nested, completely represented source profile | +| `svg-opacity-source-omitted-stroke` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs the non-nested, completely represented source profile | +| `svg-opacity-source-path` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs its path-source enclosure profile | +| `svg-opacity-source-pattern-program` | declared | skipped svg/g[1]/rect[1]: unsupported fill value "url(#p): pattern #p source cannot compile completely: unsupported computed style: opacity source-extent needs its mask, pattern, or marker source-program profile" | +| `svg-opacity-source-root-path` | **both refuse** | unsupported computed style: opacity source-extent needs its path-source enclosure profile | +| `svg-opacity-source-shear` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs its shear or unequal-scale source mapping profile | +| `svg-opacity-source-transparent-contributor` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs the non-nested, completely represented source profile | +| `svg-opacity-source-unequal-scale` | declared | skipped svg/g[1]: unsupported computed style: opacity source-extent needs its shear or unequal-scale source mapping profile | | `svg-paint-order-attribute-attr` | declared | skipped svg/rect[2]: unsupported computed style: paint-order presentation attribute uses attr(), whose computed result is not represented at this Stylo pin | | `svg-paint-order-attribute-env` | declared | skipped svg/rect[2]: unsupported computed style: paint-order presentation attribute uses env(), whose computed result is not represented at this Stylo pin | | `svg-paint-order-attribute-if` | declared | skipped svg/rect[2]: unsupported computed style: paint-order presentation attribute uses if(), whose computed result is not represented at this Stylo pin | | `svg-paint-order-attribute-var` | declared | skipped svg/rect[2]: unsupported computed style: paint-order presentation attribute uses var(), whose computed result is not represented at this Stylo pin | | `svg-paint-order-css-property` | declared | declaration ignored at svg/style[1]: a stylesheet declares paint-order, which this cascade does not represent; elements it matches render without it | +| `svg-paint-server-source-filter` | declared | skipped svg/g[1]: unsupported computed style: paint-server source-extent needs its multi-operation filter or mask profile | +| `svg-paint-server-source-mask` | declared | skipped svg/g[1]: unsupported computed style: paint-server source-extent needs its multi-operation filter or mask profile | | `svg-path-css-d-property` | declared | declaration ignored at svg/style[1]: a stylesheet declares d, which this cascade does not represent; elements it matches render without it | | `svg-pattern-affine-precision` | declared | skipped svg/rect[2]: unsupported fill value "target mapping carries a general rotation or shear at the pinned-backend picture-shader affine precision boundary" | | `svg-pattern-css-transform-percentage` | declared | skipped svg/rect[2]: unsupported fill value "url(#p): pattern transform percentage has no proved reference-box basis" | diff --git a/fixtures/web-first/chromium/html-opacity-source-host-high.png b/fixtures/web-first/chromium/html-opacity-source-host-high.png new file mode 100644 index 0000000000000000000000000000000000000000..867b0fda5acbc9625b8f9ae44c19999abb7d7fe3 GIT binary patch literal 4561 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 literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/html-opacity-source-root-high.png b/fixtures/web-first/chromium/html-opacity-source-root-high.png new file mode 100644 index 0000000000000000000000000000000000000000..867b0fda5acbc9625b8f9ae44c19999abb7d7fe3 GIT binary patch literal 4561 zcmV;?5iahDP)sZAV!b^QV5AbZW3ON_#|Qw6KSJD2=M{`27dz{6r+UL;)R$<;)}!= zqk$Mf31Y(is=z~g+Md1U&F>py?L#GLdu}x`r!$<+z1LoQ?KQ^y#y7_J=EBP^A6|C( zxZnWU<>P__WS5U9=W!f5(!&tm`MmG#it5@$p|^d#_I*Bvy{$F;xLd8QpKx(l9eX<- zoPU3;2i`k+*j4!-4JSK14juX1zUWu3gE3c!+719?$(7=rYp!>30q6AA^mm>2oipC` z-hJ;lNkYiA`FFa6H~m_A^`{SBaYD}H*f9q@^yAm&W#4eS)KfPn7%jE<;Ka8!`X~X$ zHy53_+6wH38yXfFbE~jBdH_SZ^RC1A)9Vl?EPrpg}X~MI(`hY{P00N@2RU!d@ z@Id8Xc7CnJ@Rxm8U-QCacepG&IJV`0hko_iCAHMMlJl3fvKEuH;BpPgcP}Le;ND9E z5(13RnTRmxJ6cDy!$pnnVLW35r!{~Efzk@VQbTA61$9Dkp#Ul$*0DVE!~-`RlN}mc zcEE#2-a61F-<4Wfff*6&7Q9q~H#oj-Z~)eqND!%9XPW2W1ILyBGy?QfQ4WA_xcaG9 zPvh|iC%}!hx>$M$tFHTJ9zT5Wec6GrB?sJp+z z{L_gAkRhOe*Z2lQpfdI)@a)<`EQMi!xw?eEP)46{DCOMavrioU!b`G^u_*@}I`Y~T z^WMHa^gMGgerR+;i3v^+AR!V7FgmpsdLKjVEztM~%=Q962UuJU0qrydI0rWp{+>9z{Y zmlogzghBE1tWNMifJ?a#vtuXfiKmVpzG+pqFv3Pog7L#3E8iSkyDXMPioL@4oXmI{ zO(cwICN=poCL$tob}i!bWMM*t^hoCJ&y>7b3m>cDG0z|3^%nH%9AuWy^y z4-FXcET@<;@hx~O#<+6Dr8nOD%InYmq{$}7hDzZi7u^g?E_Zni5GTwn_PO#v0UW~j zbOz=IV}XR&jSz_fBr}A*M!07+@X3|4bmc`)b75M6J*j%eiNocxcVE-+{TS%8dyIDz{*{sY@H7=jy6R=_ohnG}U! zm?Mgi2jUE8AtMqm4HV%JH*4c<=sjLUA2G@%#yK5u@n=4J8&-M`B6GQ#n&YDsh4X+F z=ZsP%B^il~qzK^g4tV1`J~!3~#s#GQKJ@}j2;!LsSr#D$!ds||Gb7xGQeb=vGs$@k z<-vfH!TUTNZAA>Mr7~6`go3&Oaj+-&*T0O*{z*15&e{&&^P6{9q?el*r>x7coL1m4 z)q$3@^3#|$*kk?7cp?Fu0G=3th#|Fch;=a_dki73&1N_@l*N$hI2(Ap+UIybcs);% z1TGZ-$5G^}^xi~kg3pDw6w@d?u0K^A4fNYOLsCd8=a4TcJ6Kgma z+zf?M#zh3q@LDW&A`G|F08MaFSd_Kdi6+akr@($hePqZID&>7H;1*7a&R*aIAaJf^MhkOqVQhB43!n^=6wKh zia)h7co}m- zV?C(?LT6?LD9Y!y?Eo43ey1r*pyW8#@SaX!SLZc`n;MT5q1>e~)Ku52B7>zcouQ2p zxPVUZtQ=V$N0os~HX7?FX7IiC^So!Sp_Zi>rCE_fZq~3M%$tS- zVNY(L@yQT@-l%E`4h|)p-B?)@52Qe%xG~1j3X^cvxz5oE;Jd)zgT$!iGV6+{IMG&n zsxNRNaI*sr0K8VT04q&@gU8nUdA734#)dPIA$r`g>%*Nfy47jw46!1L#*^p(>qrD3 z#g*KQ3mQTO2ElOP+!7?NsU@^tpTXk!Gx~dJU0GsE^$D*z(^Yz0He3swku7oU73fuH zM7UU#=(dkL@AJe)_7 zSQ{Vx9a_5Xbf>XR>9{&I4@pr!Z+1Vm@?5V%Ygaj`Z{Go*d|LHKT3a5#k2>q;IonZ3 zaZ4u2&;DhepTsXc(CtMx=SsI%`T@U3MCj13*?ax8+jZ=E-il|k4^Q{g{Y*1eX1$-V z@%^4No{5z^300dtCoWB$l0Ca=d0l%^n@z|xVbgk4eM5PR8lS4YtoFzf*5?EE=;M)J zGJ*v{C$&Cph)fso)*x}upmy6k;Cu1Tevjv=kVMG6D^-Y>j`5D)J{s* zjopVMdo@#XvaX!=^UMJ{vqnd1r%{e}M_P~E7_+iHi+$ueT`-lyRr$%24yaC`Ga!%g zB6dCQ6KF9s$!ClomCSW@lux5L8-5d!nc=s4;2ym{O$q+nffAe{15 zp)*v>?5gJ@k+HGSoE59)ZxlJ1#xpr;F(g_CDC|XrPlQ20Sg8JADTzOK;LhuHV}ul_ z&d>&k&S3>YT7@6*oBY1FnT}E4c;On@(`Bq|;kaB#b&l$}=>g4Rf71P7B#;2(5fYvV zY8qO$!X_Y!&5S?i&4)bHCk|Dv5T)8v8?*js!2-cmE_c@*2Iz--e3YK2PH zj!pmqYSk61Y!gvFWk=Z;B~9aEI>`*`plKY~VMPI3l|hW)itY#K_E%NoA$CGnF6tao z@wDRI2vy~Cq4COp6JaV3$wa{9NWZ$#JaESIpt6oAN8h%MN=L{|J2s}*gP~xd=c+L) ztBjRhSnBwt#aYgxQg$vbjbQyT`#6>`8^?gx5i^bzozqxxteDaZ<3rZ4vI>BKuEa>E zO?994rkZx#G6kql>5vnwo@9gLtaPcL8?ZPSUr=xhTn3< zS$9}?Xv$=TAmDn!`8w*fwqc)Wg{%@=oi;)h%Otv~-DzS-ppdQ`sQMkPN4Tr3 zb);zB)K{I`_L*=o1t~{9~{+VpdU0Re_x>u(wC4@g?AG@|5J2rrO1Kcw0YX)3D+wZZgO|1?u&qsEXSlT_X-hcuuoL zBVvdJB$Lpm{y}8DfYMke64*rRj&M=pTW*(&U+03ZSwM6{Ssy(hzgj(wqNIPkiN)7( zPO?-ol-cuyrEO7rz}~j0xLq}J#hK?Fb>ev*L0}}lk>#!;00bE3SaH0L!#I)11}ZreYMfZh%6?wUS2vZnl=>;LvI_?x z*2!3ndA|;C&T9PB{MxM5qFcqne=J)V8*;!SkGOW|$Xmag`+gsC!KK=Vd)>2>v8?HK z6`2T|6lg?PAwu-9%8>F{rGlf+2{`NL`P@_^t@)|8*WF>!-C=HW$m9eiyd~`AG~=z{ z=~DqTVtRY4&w6aQZ#;VV>Qkl6pCLp?jnP?NaF>>ZCxE@5>70!KC*mWa%@p=IG7O^5 z3!fG>?NI{EUf+-SzRo2f6!5)e2#BS_|1#b()X_2#kZgN^vMR$s z5g;tTtp`{)*jw*muorWHsIdGFF14+v$2R?-B%jED6A<$LE4eIYJ+Z{6E*EuMN`ivuF{s4-K5v&WVF{H7c@1NX|P-FpH^Lwpe3)TRPY|IwC#tb zw)CVZo$$S~3IxpNd*o^8wZ_n8>v6gN~n~>Gna_5IQHtf$Aa^rqjd~$M3U; z!)E*P86j=F#I|F^lopj;;SsbA?oC{8N1hwp@}sg(-h15#i>{!s0004cNkloDPRZc&fy9vKEudsVpawrSBO>AJED#8xMFeO1-!?vrjV1yvYD(;iV2v`Ti% zRqxsWodPGkiL*CV?3@<|aQx`rFCND>)GuHC#oITb2)ZN0xWZjpm>5V*!6no2DP;Cy z(mEp1MH8%bH#NzZ)3+Fm!F>2dXaznxO}w7o6> zELwdX>+!RObNos+7qsSLHnuw5lUS&*-qAOWXebR{ z5D$C+397Za0Dt(B)X#C%KfL(X>mPgglbU4*$4U-3_P{?54AMRwRj+J$9)dLQa`90s zmNa=HK!9iOVR}l=t9Iza6}4iax$>3owG|jl1mK@aClr9C00}s&&=Ey_h8tfwwJ|>Q zvv0opb=je@Z3o=-!)LDbE`54vd9T(I0#{zJ@`ixwVX>hAFd)tu18O9oyl0zkjV0%& zB!S6zakv5?(=?JfKn0$3$$;Wt#`}$&-9tb8%8f6|4vcL%;LiJ=eoxTxHYfW}8xK7pyl&|o}PA8Qj_OF%&-sDp3Wheo3_-U9&@ zVSq3fm}a(PiAe#kv@cAD+dG&r*zYtrVDU6?$96b$hH4q6rdJtf2#kUG1wCZVeP&k2rb_{kP zz|j}XdP>#c|0wNT_khskvM|j^u(kq_Jpj!))=d4DzSb2m&Mgq;*uO)MW4=G7Zbyz#~!kJS)`o-67J1yH7elsV1 z;Jd$zN7fH~Zl?80pBI3>86UgxBa8+TYKk))T~2Xm2#Y+%pp{O+`3w^{OAH|a#1WV( zjVtIRW#|+D1Eh%{(1|mgrG?5Pvj&g@KpBECZ6F9ahlj9tw(pw9+DHB>PaJQu#jz-i4B7h8_|KWLNfox~M zMrZL4X8;7j<%S!1L{}Ca!XP;(8Q|`*fC|HaZ&Pbzot&<|^R5rwcK>nty=-y3sRKUz z?O*Si1-YHZbH#iE+jC{}*kKRjBaGKd5(E+~3<{m&`8}2lx}cd&SU=Ey?b(1FfCV-O zkoYPV6YNjnK*A$;IE|VzkPzXwJjZO;m^@lUfHmX?PnY7CUR?;I-2Q>@w#Og2N0V$} zyr~0jIDGBxNNsyUHD5!Th$%gUfyMlQMN)D>MpzID&BB4s=sZA9$kYgcI5UGGU{Zti zT>f`dfblhx_`?r8y9Y+x(&a4)t(d^ER0kR&;VEO<*#DgITuUd)-H`hM_T6~D)B_~MKrUc76zT#?b<_oCD?(Jp$b+l~LH=NWaADRO z?f%W7%#yK1Zz~QZ~|f+ zov=Hi*XaXk4F@sse*qgFS_x7hBaHNT^E{B^4n!q2k6-LQ-Lu@dDbn=sJV&pUItggL>dpbi(6N;1~AUp6@|=m1dde$Z}Td245phNpVIP|R3mJivdkAymd4ksVxBRl*bM5#t}$B za{%*kf+`4d*p#Jp(R7l{_?$fzD(M|?0D^BX1z15JCN??S$uYj7`5<8{Y>d#F9eau^ zto6(}MtF6CJvqWk=P^Q01j_=zI%OLhMwOw%Y@%FBG5W`Hy5;rRBBX9?lajRg5ScXI zAbQ^Cs(WQs2k;^YMcCSVckEb^p!l9hU`Z>fsxdBU%bZku+qX>cEZr%Ld3J?u6ne8Ho&!AZ?u9pSvYOl0TF!&52|;Q6|BBW(7|W(MJFlj*3I zjt7dIYT1rQOLzPXYZcMd2nlVIWNj`sJnbi-%;oQx!+GR|$t=8%YR{0qe{T1+JF6`3 zO9T))C=WNimvF%lK%sdim@(3NS74_-Sd4hb%0c7 z1jczbG|4%67@w@^;n@N!B*jc8z-fg+1uC^pxj9*<`{CBC24$L0v$zx{0;M*ULJ_sP z15j_DZs|EP&1xr!1?$cZpfAgF2!!JvmPPNGe$Jy%?aE-U>{Me^VqDR+SRp!vT=<~cZV+M zfDs5lPBKXnsM#xxvtygYthKO_e|r~~-}8vHL1B=w;>s0fRzRLlg|XS$o0nxIR7jLB{a^Sn|HPyK)% z^L^!MJoF_X6|D=*`}AgsaZBGn5FBnS&a)Ud3%}cHjSk=RI=I2U(4r;6|nO z>ZI@37~Bz`U?N&V9gEoPefy-;fb8Fc*r@HrqfVRw+Hu7D5#8M}h1obdyyg*~70+WF zE8Zs$|Z3 zUnk?65pG9;Oh~ZQS}A&bUwvz*HQQeR{!hskdczlW09N~Hv@0OaQL%V7Lr~{pyQ;7j zh0(wT*c`^^n$fLQ_V65=4aFxR+QH$ri?&c7^}`+AEL0`-&@W1$+-=?$u(WazxOh zXP0wb0Cj9#i$<(Gj$&P0tUQ`^)!O#0;#LQ);U4!N?jKS6`H)d)y>W~bpXlmru&|G1Q7mMug*NIn&ED9tG`D~b3mbj z-i4rFZ<`j8g=}F5)v^ZVgmrLOGIOWLv+S`sQ%oqu7M*1K0>wC>Z;sk(pEcgI2tlYz z7e{L1-;^S}NnP^FR>*JD4&b=|t|Nc?>H|;Rh~0|ol$#YuTHLY8?9hdo?bry8tsoi# z6J}<47K|`a*Rzwu)K~~otLLYE0X-*g8=b}HCi;eGPklY#GaH!ttdsGwG@fB$=OXS! zUzKf)O+F>2?ZoM)esO4T{4%C-kVf=694nsYwq3;rPaq2y>IG~FE5#969~qHP4p8yC zUeCMb&XAw9y;?Rn_i%*g^8k!_e>UcOhoBoz-QRjmMsh?eNlt9{NsmoGC;0F;j?T}_ zhliNcNAA{w@D|IhYUJ(WX4tLuDIxDC=6APd#8)DSPf37l;{v~>4p`{0AOVho_dIXX z2@Cx3dlF}pb8P?L##8c&$zb^ASw4Fu+Zc7@L|CvJBX-T1%tz zsYXsdCKy1c82c}b)lK{u`=iaeC~=KW81t}(&&)ZCg`?BDILi2|Vl4<$oy(Xvr!0*( zgy?hVCCp`ovE>IP`9#K@_dP8!?)hlKPD~~GGdC*P(1Z_yw;da9TCi`AZAAz~a5jSIL7(HjuDl)L@OF%y3HuF3P@&{MMF*w&Mxbix-O{QJpu zS^I3Q<9f3uMXA`A@e6i+Q9PjG<(c}<_U)~qNx=`HYb@4F1G{%T0004=NklUB%S2bG~OZ#UIlY^?sUG;I35$m2IZ@W;R0TKBylBIvzPGZD_P?KB>%waif~ zsC5+de7(YslQBCQIT=T8_?Q#yiBZUIaVJ3!C!qu`$ph3o_O$pDcib{QA@Z9~eeco7 z53V0PiiCI_1mhvdfXMACw#7LZbRcqNCzQRtN^`-GBsX}g&TB)PFgyqBKMzctf`9Yo zwmG*qcKmVq>1nM$+TVGP%R}S51^u*jh@^c0{<>Hjzn<0*q2k;IX#aII0bV%%=l-}n zT3Vl%OKJHucKmVq|F?1Zf0>b8J}x^zcKNvM0NLf^vIAt7k6rJ8e**vj|Nnhhz{&ss f00v1!K~w_(`1twzp>_r?00000NkvXXu0mjf;zPt~ literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-blur-absent.png b/fixtures/web-first/chromium/svg-opacity-source-blur-absent.png new file mode 100644 index 0000000000000000000000000000000000000000..44e4060d1645c8d1d5950d5bee0384c82c27ca46 GIT binary patch literal 3340 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 QA^-pY07*qoM6N<$f}#`A%K!iX literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-blur-low.png b/fixtures/web-first/chromium/svg-opacity-source-blur-low.png new file mode 100644 index 0000000000000000000000000000000000000000..2a8784ff4862b74453412a991090fd1df4420980 GIT binary patch literal 3573 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 P00000NkvXXu0mjfSL&@R literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-disabled-ellipse-height.png b/fixtures/web-first/chromium/svg-opacity-source-disabled-ellipse-height.png new file mode 100644 index 0000000000000000000000000000000000000000..14165f92613d1e2004c1bfdd1bf9f002fc83cc57 GIT binary patch literal 3103 zcmV+)4B+#LP)000ZrNklEAsSm{Em>LBEPwvXU&FV2r0|U&j{qL}cm(j!M+x_G#F!6Od9e(oq*7wasSIWo zYHf8goTsr}t?TA8;Nra=Uj-oMk`Ir{@~9jvLrT$F3;_nl{&Ebdgn>U{eDKBTay_jt z>g7kPQ@D@gjsW1};pAC4I?gFZvk2p@CRv_hXt)CF;q3X`c;?f^YE_oY@bvlS!`tcO zIf4_u?${duEGx%P7spQ%TpkRMM9(%BQ*7@09L?VvfCT`9WnlmaJSV7N%agPEtW>UaiCa$F2a%(aFKti$c?AYqFYykVKv{QQ$ewst zI@0R9*O$NjTrWO^J&&CL!20F0mx-QkvR)YY+;XV2Ti2CE=8l!Y^f{|RbXj8|=+T-pKvOuu~bD)UFkGbze$gRRJZB1vRV zb0kO(MxdF~kADN~Hl;f<2Swc)6RQDCWEPS~bul9ittzN#mc=OZlHT}B)QexE> zSj(Kr?seKtiwiMSU8#(Tp2M-JlP3!pZ)u?t1Fpe(G>e!j+eVm3PTBZboI3{mOtQ3m z`NQR#pVa-n4#4o_DOzk)hw6C`-!qXKAFN%=v|C8<2B)J={r(+Qyva+gHCUfm!MQT! zfP^8g24AF!?@1&rTpAX`>DhSxTlgvfDKAc*(t74kWfoog9_eH}!}=oY@jNoGnj*k- z=@jbuBU>$IBv4Bp&+Pph+9E{QP;-I@%##g{7>-WH59gt6@6)#eSe%@tUSDu?RMnd7 z76!VlX2(FM3JJ^uwx*tCVi#dSx@ROkKbR&pu*s~z?uI<_{9)H)`ZV$uFyx$vrC?kF?`Re~^h z9lLN4Ic0dA?#NRB7Umi_ zZM&4C{$R(O{<4Hp>Y1EZ9G?WwVlF9K@K^LO~dk2#8wT8wBydY4BQtOu7?* zl=5=P^cVBY*26b&P0d|LH02VkM-9}$COitQueq_-qXriwMGJ0a8Oc$=NcCd@af{+m zWLj$jPet(NV!1Z} zpN9l4j`%Ui_GO(`@CYU<{ao^I^o-KUlyrf0`x@ip~eRRXvO)-T!>qLI{t zYRDN?+cd$nDSOqjmywroEei=rPNs)GPdg9s=dm2v8vw_1>8k9rRi9Mc_kBo=9*x?Z zzr{VicHP9 zjCS+64{m&+Blq8(#;`AdhF#4t_=aj$l+`?Sy1rKm2MM*nUfZZ)QU&K!P7gpgLBFvp z<*QhxSWwLkPfQ3Y7&seH6$%5pa*~f?^5oZ0&0pWTak&A2fk5<)jhzc;pBtm>Hcf`~ zAnY#eL`kX!#4f(i?6n+Oq(;%IVExDk;*b>|d`V`kXe%Pq_bYAACF{B5iHU;RU(6u_ zrar=L6l=~ErS>A6tvGq$9hySG)oCQX*vM|WxZ(#Og_5gLJ_p$&G68YG88FH}{_v;f z7&IR%CO+wNN8`DaRR}oeakKpXkFe_e?|<7BfL*OEoQA$3?216{XRd3mCC65>3T)zK zD&z!R7m+~VdoaCIO=A!Wd=KkKJ}DEcD)k+a%~hdwOM2Fz4DNC$j(}XRD|!P$j)r&Ry9Rwfk~1sZm!%0D!Wl(dmn*#+j)52EaMao!RRC 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%0D!Wl(dmn*#+j)52EaMao!RRC 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%0D!Wl(dmn*#+j)52EaMao!RRC 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%0D!Wl(dmn*#+j)52EaMao!RRC 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%0D!Wl(dmn*#+j)52EaMao!RRC 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 literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-leaf-stroke.png b/fixtures/web-first/chromium/svg-opacity-source-leaf-stroke.png new file mode 100644 index 0000000000000000000000000000000000000000..5ae9681a517cac7e7afd26f29d755aab1d214900 GIT binary patch literal 3696 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 literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-radial-absent.png b/fixtures/web-first/chromium/svg-opacity-source-radial-absent.png new file mode 100644 index 0000000000000000000000000000000000000000..6bff1f1285b29958231378bab63c6267805eb134 GIT binary patch literal 3092 zcmV+v4D0iWP)000ZgNkldE8~4Ydh<`b$Gd)l1VYgm|~xY!Tbj6 z5dc^p{Oka7`)=k!W34qNTVwNNPdVexxY@+j#c=T%kjx{k2LZ65X^x&Yi$g0?Tgqxo z-Hq;eH)m)0x9hslb?uXksczzcvq8_W=N{cgN2lm7~@ZM5X#|rUiIStsA*Egrz$n0$a*P zci!2y{?*?%XCF78elfuII{<*Ych63n*<7>^2jA|Hz)|b6tM^lEGts>h3@-6~Cm0wT z-O)*yAFe*UN$Zu_f9(MP9zQ?*)_d>7XCR;#eY>(}wT8j2_lw_)MU1BfB4kEZq6N6) zItvCoIm}A*CBYDlNa^MF4vI8@LH`Q2DlOfvO`#CJ<$TJJ}!zK z^!~`Fx!H?Xs}FDE<+<5s?F0axGk^JQ@WF|?;#&4>%bvZ-W)csYf6~+f z3=Zl(agUBWZgFBUr{U5)eVOC1yD!}hpnLwxqdb}7@sQOM33XqP816Jkrdh-SSyPed zp5kS-fJhJEC5{av+mu}rlf*jm#PX-_Sj8U>AS|9V-Av_%e4*+a5(n-vm|X1;{WcO( ztz{6MqVYxPldBe}T8G$fC^^cA-4gOd`}B1E{wMQ50C1k}e?x9GZJaw;o_K-en zJY<`=^WSSVPLv)NaL(9BAIloSgD8Dqj-nmBFv~2y*BmT{%fq<5JD_g^(4M?-(AYNT z;+mF!{?3*gFn*umN$Im!S-D769xhL8E0wK5pT@*w~~ z%bJ5j^=ZwOSN&e4k9CjL8p#z4WC>=?FxE4NQhLc_c*X$xii;@*JJ7=LG~Pw-gpX0D zX9ssi^Nj%f;)(LJ(E@Jt{;GQ~x(~AZG^jN^F(;=X4C#|r0Gq7u@xD{t2hloViqA-0 zK@@3rCZ`2GOe#nH%Zw8}cgV8+Z-q;D7ikUj=p5E~4o)gVhYQt2bJz8`24FS$Py z=~Ru)Qb@m zLsBk*N5qWwEc7shl}}vADMA?uH;d=^!ZL&@881wUwE~R8w_R_{{s7ue`LLBtr}b4m z$4br8nDz-SBwhqxN`6oGgzjcjPD?~OjY9a>YC1y{p4-;-{#Tvxm z+nKos0CtAPY%L%O#TE+)hm#oa8nyKML8WhTQSG1>p#K1@ZP*a-F@txo33V^K)5;)> z5O_)#Va3%fs~lXfO~Ymn09N|iDt8_&pmAAzNpj^D2{AC5A$Qm_r3KGT7^ds)B1-w{{U+MOy z5|}h?|G#>UvKo2cX>X;{N8ve=6CqzFUywo?SqCTwZ9-t7hY@&>6AVtQq4+D3VpTKx zo9?$10dtcJVTkG4MtS+@Az8cf($_{-P?Y+T6FqeABvLaorbEtAlfiSK``&ng;iCgu z2UL-vf~*Q=;Uv&p!I15CmSH5?Zk-BWUy0*-8QNAl42=QyC88~vDe&>EnyjM+r4k&o z1ZpP388|zVEU7Ua=|x^%&8PXP?Ke5~N%|YLgIrHMs`O?2;&|8K@p=Ghv\l*K%9 zM(j*4NxlzyLsvrD;;m%yER8xo4IHb-Z9hP1MC;D4ZFu|QpU^#K?qPN2HcPX*NaPe| z^vP7CPM(%A1~&Hq5SN!(^shakPSH}efUp;xOo{Yl_AIAxy#SU*=_{(@-;RvPy36iq zB;9xD+xx|fv>@5G!zQmT@_L2X`_cmV@U9ur^#CAefAQHLom2~0;f8UPe%Man@PvUK zR#JZ;KVyYIRj$qyGJ7lPIEe18=-!Qey^�?YUSYmH?m=iYk4n404TV%H6_t z3-F9E3AMu^T(E@ZJx1Gri zzFjx-nr+IIZp@V>ht^4q!4%dly3^O)%Wsx%zTdq6ac=-L!slNwm`HUxVwn?VMj~^l zLM9|rCC(UF0F>XeM=bPJEkIe9-EpE(RQBapOeDRw}94um>{@-PA*FkY48Rn^obY(BS#K z(&%&{npf|BoWK0W2nOa*)L^628=t9rnIjnOLY?Y&Y#P)S{p>hRTxBY^nd+W3be;O` zAYxdanFj(eQka)-zsD&7xX*avBHm9glDP=yR$h8SH=e7OMo8*Ww@6jii3JL8Q{B^Z zP|GIV6VD$m&+Wqj7zya*+aG2xUrS$`U2O!Dp4*8vQk~@45-ln)BRIAPV_#A+m@hmE zc^m4Veksn9ZS|#x0#FRAx8LKuLwKI@_)_B-6WjAiSNmF#5_dK;<9ubvBZkT8f%yH@ za|(d-Pj^bS-!Ut)+w#r-nxhk(6SLQl?{-%ELo!O~P_K5eu!s~_Q;XD8>qasrbIx^| z-sQ0DJ_p_X*+;l04zt31?&Q2kGLJIWG){Hu8GUx$ymtYH^sDq3BWKKhjUvN{2XU!PT~Mi*Rsv^nLNR1P}G!DgFHc=t9hG zHt#kc--m-kyf9JKnUWIml7O078T5eNN6bhu6dlYC2YAD72gZ-_dm$vdi_hw>{H0K6 z$KMO#jrQ5;fbIf}GVk+9GKjk)*gX0Vp5V|qUg2KN$qV?K*>fdy0)4;Bosa#CtzWIn z@BWKnYginI!zTzB9QyC_U&G~@*=_yi0E`rn%}0dFJ2(+eT$;rb9Daq_;+COjf4-Cd znBND$xU8_tHD2$2KxqpHN8#ug#*y=s?IMFf4c_gUzbk+%3(4sGBVI-Z`m_mN2k000ZgNkl%eIYZ*m z8u%C>SqKmupS0Ejj6e)li8JTnWN~uZY6Ft>yH{--?W@2RUJ~nd9`Y+SC^Z(i!-UlYLa=yY}Z9WRqR;8=^&MI>8DdM2l6nL3RKL9D39W)Xc$?@6TWS*pNEz z5kNj-!1-MIC^9E<2}Xd{tIHjAoPGL=KZGdh@irAJK^QVEKshj~6d~ z+}Q6YkS$mew=Gt2(;w=eVSB3fhC~w17N1PGlXdL3$M#qz72q zDmM52JRnZ2-~4Aw2)6aV*fO@>1|T1v#F!K-ElI~NYbFUwjvz)Mi};Cu zD=szp1MNVSit#w&7qPTTZ>NFqKZc=+EQ0P~!!**Dh#ghbaB@1Ge^C0^8vr8D=@XaU zV~%uOvoxJU*L`Xew1@yX8w9^3+vjS6A*_lmrNtCdVa$yarO(KNKxNbpXarplVdSx) z>KKk6k00LJ-T)AJ67Hy2NwJ85ypOJR@jgTPP+uu*FRPzcP0{G??lF@G%S zW2*ntQSk2h;AnRM!@^sA5vTV0Ol7gkW_}Wg#3K)n11$hMOhcs|h>Ago<|{Q8bjgM8 zslpB_2Gm$0w2&rVKhbAm6m!HMx&kpV$}X06>$(9z$l25f2C2+@LSEPWrHV}~?==M@ z&qTMD^ozO~KqMyN3Yg%}rdH`**1(Tr^#3C2r^u)emIjeX88E4%Fz0aPcj1qrBT==ATUNU7Fc{Chu0~EE@9ER@2Y{*T123qzn8)Xv!2lz$_@ZH7h(J1*YeU>y`qzoc>Ji$P{gsKI9sYh(6p%2vTktO5uQTE zBhshX#5(hewapD?=(4)&lB-e0M{kSh_6aKbB7mC)Tit3t9%l1j)McqYs!9~cp8n4u!hdE_eyx#V^- zHP?c90?N5{_|hqCR0!*W`ibz`H4CQwaGT{oVDKY_$j1% z_pg6$3!rW;l=3@ARUx_sloZrkRd!YEOH{$57pbc66(hZJ_8u5w0Rvnj4kIuGSR+bY z`lhsCv!;9&;BlkbJW-W4Z8r!Ye^?s=A2jKE! zeRM)as6s4`#5v0TFcq(ksQY1*&MIeE@Q08ko*Z)S2cr4`g(JGQT}dB2gY>tZMe}f2 z;pEP?_+|igeZjx`9#&4BB4_k`;UJ#n-3`?sr;Gbgt6&3NivIqDXp3xGM3^#}_qadL>t zA$AMQJ#n8$pwfV6a|YS@Gaq|GU`DsrlbPEArO3wi1c0{n!&}S+IGJKh3!1`4#(UN7 zz<_fUu8otCBy7}u3G-B9wt%@Vqar4`cP;((#6`?BkJr7Ava-2sUo}>#6&NCRF~m_# z!+V~G?@pWx!5YFq7Ady&8DskYxzLpzxGf+jo_(P9e9T~^c-h%c^KGGiyM3_bMnKS% zc4YxhR?wSk3gWvJedf%`Le<?5YS_4e5v!(HsgUA}pRO%zQqxi!IFR?UHCM4Hz|O{4U6)Ne5T4AzG`O=zq$zSj%a zwMmTBw)L3Y6g#}>Z30|2beyI0r@&Z|R(Mplkd-BaX5gpOK;!I;o!pDv&j z-@FeZ%gybeO+WJ##<`*?QYjU~;O(wi;ywUyzsr|DVbHgF{G?c)X37rLTdBzU8T}-( zf#MGZoA=17_Q*7B@IA8LXt!%r&6#ttv-Dd7(88W>-E6R9zA&q7Dd)vjj> z9u3X+x@9+IS(PB1!-w9Lk~c`X;QcPl*$05pr$3$l^uzM>X*xX0Y~@?WK<3boulI?4 zxEA_eN1J;Tr4nz674U1Dkg&dDuY2c>Zgk9-uV18(2aB_3X|ZVHOr5O}`i`AXF=Ctd zj+%d@)DZiK1G#oxk*)Sp{QdxZmG#BNiy!jwV{RkpS&24JV&-Qh{3!|F`Qjea?9lXk z><8D)dJo3m8vtL^$9H&zi~x0zR!9A5m3H;CinmvN$FYnX1bD=mbbMy_x9;I_8!&kM zh!;n&+dMDyrS5jWb000d5Nkljyumf~@)73U&H14kW)aeX;neyD)-m5wh0;8WM1-A~IC zSm|B$yi`39)GBUP=lLFvz~6lEuJ2PnE9dve?Rva;pSK(L#m5~1V6nTi2mNwI+hAO+ zLAK&8G2mRA2Vgk70d5(-Tx$w0ESBB!1ORsL-jreIzTh|>fOp;LeJngIhE;(^h9IN4 zte)2B4GF^cAdUP%Jo*f5cacCwfGDm+;wk1C#dlHWii;=DH~;yeY}W3`<4^!0rTLTR z36bbYY5<6|7#0?RAq?b9MgmrvS*R)ogr=GYDy+}NA;IF0)rXOpO7y}bYh6Bkwt4$9 zuP@v&$3XymN{c60`>6G9uc-IYY3&`?K4599h@`+EtG&3yATNqwQm+sjSd^2v1e{qw z!KlTZ`RSMmy(w>!D_MF6OCR;N@l%I+;=iuciT?d5^2S9=dsp&*CC zQo6{V!}JfE3O$gSXNkcnd(v)3HQa{8#7f6F;kWQgLY<2*7R@(8ps6Q{HgjjT*pFbc5?Bz53*+?B|#iu)5Zh3Wu&%Sb?`EsS-1 zRqapjZ|(>Hf@gK&uS%>+ueThks3m7AF$h2gmlCXp9|}>y4=iZK7=lOzWLyIKVQLx_ z+9(WFF;K~wjAC3BA&L{Si1m5C2whi(;b;JHzJkj4j7tWGuVEkYF0iG#>1FN1` z1Sj|No7Vrc0J`2cC1x6v-%X|9TE*3w)<;;c7$AT$bZ|oD6%wD3zz7~QLK}7$CyS*T z;KRVnl&?64X}uWTxv6d{4oGfy2LPd)+1J}b>gfa%B+X=fHLg!w%EZuKLkG+7!6N+w z3GVZr*7voHTm$4Cah+#ZRF%2jCTw%H4#{~_B{*&jgHHfJ0(A^itEpZr;M1fx$sgxT zw=vrwJXs=2!_Z^-^U%8yo+06xL&5L_A>ry!Sc#EqB4`F+e2Axpkai*l$16%4T0VVw z4c+`W073jQ6;NrYbi2w@>j5kyQ6$hap^nJ?AOs6@9$|f7_8kB)!-N6W6N;zx9#I}g zKC@bW;}a;9nEOYCv2}BXO5O;r^%jSF0EvLf!>h-2&boD}OVv(k1F0RWZeE$nYA|bD zrXTvULxvskl*pbk)i@wQArbi^{4O+3%GTCHM}3PkSy$b3CENty6N9bRi@4hKofyo zLv7zY>3bEp);zX6)wHmeB4Cck+VA3Q06$Qnh%OepL7y$Ag4##?PpW$|yRB3+;wAu} z%0i62_W5SF9#f~sTXu(M3)}Tz3nwU zkZ=RuXSN4{@{XVNy347rU_0%1Tni8A^I#K>f_Q z^(8!e_J^jN7PV@E#u!50hjh`O!+KoJ zX|_-EvV6=cxZk_YL0k=dM!x*$?f?MuPK%KoYmLT1`~){zn-poI3mZnsfd>T1TpamUB z%NPKf&_?a{9A^N+5W0Q0bn<|K%Mu?&_Z2074Tl5Yr4++*_UQTS(X;KV7rW2Th;ACI zj33^YZ_i^ zPs0qKcQ$SJWw*JD2l&Kcw|oC~#=Q*fbI}92Rd|xcSwCJ`p)jZLnv+IpV~LN@B$=@V z{6VRk_~Ym|iK;SblZJ$kL7Ow6WiTkijd6U-XmR-XE-jY6pG8fNZH63KO|gRThNkLN z$ecDu^MF%d;+hzcd8|;w?6eqRNELM$lAhD(&aO+pz&~yIU0z?h8+Qb68}3$zy_4m4 zf1y0IwoK(EY6=o3Da=lQd5P>&Z)G~(vJkD${d`Qlt#NHTP};fC+E+>f@aL1n-AT^=9 z5B;oLou$?3v}ZK2d75Hqcg%N?YYltbH!J7|_WPR?{CIKDUga14*aI=Q+rQrb_$Dq- zyVWTyjjFX;e$(}&0UDcfXO6(FT|e= z0Ye}9uw55U(d$u9&6mHv2R`++7)|_4{r2}Bm3;J1ov=@QYcpxbR|fFE$K}8NiCK3# zL8zP~THvBXK6UsH4t{k2PO5hQ?loTA-dL{E36Pwn`RazL?QlQ^u;aG?aCIOtVQKN& zxfckMxH>~gfpfSWas9v~<+fJ%>iA6oTtApfKfc4uX+I^Mo+Bdz5`Z!P9Q^yI-wnX^ zgB*MN3NMff+jGtz@ng{c3jjYIwI2I>p+A1P`+FbXJb?cK00960p%FnH00006Nklpostg literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-radial-low.png b/fixtures/web-first/chromium/svg-opacity-source-radial-low.png new file mode 100644 index 0000000000000000000000000000000000000000..e5f17c55abde1c01d81689223cfa3a6b3243dfa7 GIT binary patch literal 3387 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 FV1m$R&N~1A literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-radial-simple.png b/fixtures/web-first/chromium/svg-opacity-source-radial-simple.png new file mode 100644 index 0000000000000000000000000000000000000000..14165f92613d1e2004c1bfdd1bf9f002fc83cc57 GIT binary patch literal 3103 zcmV+)4B+#LP)000ZrNklEAsSm{Em>LBEPwvXU&FV2r0|U&j{qL}cm(j!M+x_G#F!6Od9e(oq*7wasSIWo zYHf8goTsr}t?TA8;Nra=Uj-oMk`Ir{@~9jvLrT$F3;_nl{&Ebdgn>U{eDKBTay_jt z>g7kPQ@D@gjsW1};pAC4I?gFZvk2p@CRv_hXt)CF;q3X`c;?f^YE_oY@bvlS!`tcO zIf4_u?${duEGx%P7spQ%TpkRMM9(%BQ*7@09L?VvfCT`9WnlmaJSV7N%agPEtW>UaiCa$F2a%(aFKti$c?AYqFYykVKv{QQ$ewst zI@0R9*O$NjTrWO^J&&CL!20F0mx-QkvR)YY+;XV2Ti2CE=8l!Y^f{|RbXj8|=+T-pKvOuu~bD)UFkGbze$gRRJZB1vRV zb0kO(MxdF~kADN~Hl;f<2Swc)6RQDCWEPS~bul9ittzN#mc=OZlHT}B)QexE> zSj(Kr?seKtiwiMSU8#(Tp2M-JlP3!pZ)u?t1Fpe(G>e!j+eVm3PTBZboI3{mOtQ3m z`NQR#pVa-n4#4o_DOzk)hw6C`-!qXKAFN%=v|C8<2B)J={r(+Qyva+gHCUfm!MQT! zfP^8g24AF!?@1&rTpAX`>DhSxTlgvfDKAc*(t74kWfoog9_eH}!}=oY@jNoGnj*k- z=@jbuBU>$IBv4Bp&+Pph+9E{QP;-I@%##g{7>-WH59gt6@6)#eSe%@tUSDu?RMnd7 z76!VlX2(FM3JJ^uwx*tCVi#dSx@ROkKbR&pu*s~z?uI<_{9)H)`ZV$uFyx$vrC?kF?`Re~^h z9lLN4Ic0dA?#NRB7Umi_ zZM&4C{$R(O{<4Hp>Y1EZ9G?WwVlF9K@K^LO~dk2#8wT8wBydY4BQtOu7?* zl=5=P^cVBY*26b&P0d|LH02VkM-9}$COitQueq_-qXriwMGJ0a8Oc$=NcCd@af{+m zWLj$jPet(NV!1Z} zpN9l4j`%Ui_GO(`@CYU<{ao^I^o-KUlyrf0`x@ip~eRRXvO)-T!>qLI{t zYRDN?+cd$nDSOqjmywroEei=rPNs)GPdg9s=dm2v8vw_1>8k9rRi9Mc_kBo=9*x?Z zzr{VicHP9 zjCS+64{m&+Blq8(#;`AdhF#4t_=aj$l+`?Sy1rKm2MM*nUfZZ)QU&K!P7gpgLBFvp z<*QhxSWwLkPfQ3Y7&seH6$%5pa*~f?^5oZ0&0pWTak&A2fk5<)jhzc;pBtm>Hcf`~ zAnY#eL`kX!#4f(i?6n+Oq(;%IVExDk;*b>|d`V`kXe%Pq_bYAACF{B5iHU;RU(6u_ zrar=L6l=~ErS>A6tvGq$9hySG)oCQX*vM|WxZ(#Og_5gLJ_p$&G68YG88FH}{_v;f z7&IR%CO+wNN8`DaRR}oeakKpXkFe_e?|<7BfL*OEoQA$3?216{XRd3mCC65>3T)zK zD&z!R7m+~VdoaCIO=A!Wd=KkKJ}DEcD)k+a%~hdwOM2Fz4DNC$j(}XRD|!P$j)r&Ry9Rwfk~1sZm!%0D!Wl(dmn*#+j)52EaMao!RRC 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 literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-radial-translated.png b/fixtures/web-first/chromium/svg-opacity-source-radial-translated.png new file mode 100644 index 0000000000000000000000000000000000000000..ab10a2397ea83a0ee5260755fad073c263ee6be2 GIT binary patch literal 3113 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 DCwb-n literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-radial-uniform.png b/fixtures/web-first/chromium/svg-opacity-source-radial-uniform.png new file mode 100644 index 0000000000000000000000000000000000000000..418a87c6a8625b3797b38482d7429446490fe03b GIT binary patch literal 2105 zcmV-92*&q`P)000N>Nklu-e}-#j7Wt-b)@AinA3y4}2?twW4Irl6UEdDpmyveDTotDw-kWK3 z(*Z;^q`vRE-mY%e%lql$e-9LpuK)m=bQd@M)wM){wRlO;C_yYV*AqivD1t>5Mgo8| zhVkO6Kfl=A-)%nr1;Ami>(v0F^l#qvXJ@qAG)0R%#)bscCxb|&=omzaB34riQgK$| zaD9VpS%3J?elffXfaE;9{i*8*xHZx4kmd*!r;iEf6g&i>IUXkRm13%XC-XYU0Q~vq z)t|pv-5Z@=10cfs+;@q*&>Um3N&t=oM?o|KEe>jq0f7(&i=tF3;G0JPpQyaO@5Uc~ zdiwnr+!pq+UIsAy@MG8a;7l^h@I9^9927@HgT8kpZV%}LYpYgb2(gksu1`!2DbBIj z_xkqT`u*==AL|7G`TVM1oYB(()>{g-3pBc;^#x}HcwnGvBR_LM!G8F@YD@(P`85I- zO$3ozvLl!3>Z&~4hl2uu>!l!~+;nsjAI-HBrxCd}yNA zb761{O?p2QZvV)5@*dcpg{BK;QQ^^+*FPeXLT8 zfFKI)h}KwBo-B#vIHz`GbAha2T0>ib&{_eMVLTLoJf6kbZdBVcvuMUpJB9D3h5_#Z zNlK+(OJMF+WOm*dgJNFMfPs-A8;$%Fh6R_ZB0b4%^|n76%4IJrlHH zsBZmc42Z)9=OLeDKmMd|<(3>u395>4P_IOo*~|)jC+h~Ef?jHeRQ@~w>Cj_Bgjiy| zQ!q9)P!A%3o;rreIE)B_t_LPViRhwZnqpqVdKrKhc?>KX|yub)nFT3Qc3I3#WW%ta>%$RW%wH%D?gYd zZ9Ssi`mt`aNbj(o0RZaw*GGpCn~1oaG{tNk1R2HRP+v?W9wcj)HKosN%&VTNK~!e2X)abO_EA04z&^O)U&0;%pgM9cF$$2j9+UljBzm3UhRRw2XgCSu4A@~I$oYVSS*Ru*x zR*(HSDr;w9{kKx*PS|@)>1b4NQUOovg~%mj_S_3G6Oby+RzVlkI-HleG?cRuU8^)LK+4X^I&+bk>j`O>h!J_39;`yR^OwtdJe^*)(p%J~z>Ex|4jYcRZy2_T4K)(V29<}=8*YNIV>vG$5%h9J_Fu3hovD2jfw9V1E-H~};sQBgzc_068J*tU5 zJ%l~2mm4V%2^bbX{ta!TaZ64)m19Jv##bs5+zl*qlgYxN^Av{5R3785J0nKlWBdK- zaZj6CuLBUgw|o5c-@`5TMi<_taW<$tX1Bf(D$M&rv|O58a(^qdoMDiVQ+4$e_PSo( z%9^g<|5l$a`l>#b{A#3k@29&Lej`igSM_lj4!L&ykq=h3e&(}IMxg3Gc5uJ2 zPQMc$dz}Crdz}Crdz}Crdz}Crdz}Crdz}Crdz}Crdz}Crdz}Crdwur+{s#a6|NmT{ jj=cZ?00v1!K~w_(1RhH4jFNkw00000NkvXXu0mjfl;Zi6 literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-opacity-source-root-transparent.png b/fixtures/web-first/chromium/svg-opacity-source-root-transparent.png new file mode 100644 index 0000000000000000000000000000000000000000..867b0fda5acbc9625b8f9ae44c19999abb7d7fe3 GIT binary patch literal 4561 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%0D!Wl(dmn*#+j)52EaMao!RRC z06_M2eUVp(rrlF=>sePxdjzT%Y6NyPXaRsguN__I6lMZ-9mLf;XY zecl&&2s$tpaNW9&zgcNam!HaVMV(G(P~@6NAjrp&)}zJnXZfp`sBkxc=&IUn)PPT8 zoO4pC?4d z-1A^@ZJIyhycBZAWWD0Es9@B6lJ#1&sMV{v(G~onc@N1CbudQ;63GNgP?Se$)h=M`o1{Hi7!L@6;F zgHos?k;A*d5PDTW8?V9+$L(snwX65%>Fl{e8<|BAm)fPtdf0}YLN#5_Nh=?z|4e&) zF!XyfkqaYQ1^|H_fOx#61ZU?!cJ6&9=;!LV-tlhQ?~Mz%1ym1@;=zGtbDz**40mbT z3UWhg3c1abVf2~_zAp)qlYU1dDdTf>=J|p{t+}jq>lLmxwQ+m-ZU=yGTmSlI_5F`b zBp>u>F*kwp{R>6P#iTY>okDUJa-~px2ci93^{VyFyUo^H>=*C@>x;1Sv1MRG zBN2wz&I=Q#99FPzWRt3s(u+Dmr0CnEuJ4`4l8<&(j;92g-T$Taeey82(cQMXy8*z_ z#?9vKud8P-l{s3n(Df~gVX|8fWn}#xhmTsKE1NZ@^rmV+EU7&2lGeMg!wI~Ryyp$S zo$k&puLd_>4{)^M*(x$y{ z_3e6(3>eyUwR-VGl7H1f&Gj~SYV$VA17uzUVV;E4E4&`8-v##$&>c_Bu-V-0M&%xN zDcfd!`JaC-belDKo9bhr4_DeoX{Sk^ZF7-FCq~brpbnC(Pt_uRNPg!1K8b%f0Q~uB zTL1j7;mIj)bhF1vk+K$(-^DTC@Hj1fjzI8byH2qyZM}@!VLLXg`vJhiZ2a(U{Pcb} zK3g20s!+R}*Dei&Yucu|LhCnO&F{HNGiNa->~7QM`c~Ti5r7}9jpzSGr^|;=%E79v zRz5w%c@WV=>yx4NJplSZt8KH%@H&ds?*8Wm_W?i$%5M7b4v*_Uw~3`-EXE^gA;rJ7 zX;>&uc+~mNZ5A(IhxFC4ZV00006Nkl
diff --git a/fixtures/web-first/html-opacity-source-host-low.html b/fixtures/web-first/html-opacity-source-host-low.html new file mode 100644 index 00000000..edcd04ae --- /dev/null +++ b/fixtures/web-first/html-opacity-source-host-low.html @@ -0,0 +1 @@ +
diff --git a/fixtures/web-first/html-opacity-source-root-high.html b/fixtures/web-first/html-opacity-source-root-high.html new file mode 100644 index 00000000..05d09148 --- /dev/null +++ b/fixtures/web-first/html-opacity-source-root-high.html @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/html-opacity-source-root-low.html b/fixtures/web-first/html-opacity-source-root-low.html new file mode 100644 index 00000000..7721a2fc --- /dev/null +++ b/fixtures/web-first/html-opacity-source-root-low.html @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/justfile b/fixtures/web-first/justfile index ea2c5d0b..cb4bced8 100644 --- a/fixtures/web-first/justfile +++ b/fixtures/web-first/justfile @@ -69,3 +69,7 @@ assertions-test: # This supplements, and does not replace, the existing full fixture gate. assertions-gate out: {{node_env}} {{cargo_env}} pnpm -C {{justfile_directory()}}/../../packages/grida-reftest exec tsx {{justfile_directory()}}/../../packages/grida-reftest/svg-assertions/cli.ts --manifest {{justfile_directory()}}/../../packages/grida-reftest/svg-assertions/pilot.json --out "{{out}}" + +# Ordinary-opacity precision claims and named boundaries through the actual CLI. +assertions-opacity-gate out: + {{node_env}} {{cargo_env}} pnpm -C {{justfile_directory()}}/../../packages/grida-reftest exec tsx {{justfile_directory()}}/../../packages/grida-reftest/svg-assertions/cli.ts --manifest {{justfile_directory()}}/../../packages/grida-reftest/svg-assertions/opacity-source.json --out "{{out}}" diff --git a/fixtures/web-first/oracle-bake.json b/fixtures/web-first/oracle-bake.json index f2ce8c04..9428d26b 100644 --- a/fixtures/web-first/oracle-bake.json +++ b/fixtures/web-first/oracle-bake.json @@ -5,7 +5,7 @@ "bake_script_sha256": "2bdb5f933d072a1e87c9a675c3342fcf506c0955c0f5c26e2988f4e8fa37c4f2", "capture_module_sha256": "069296201718c43d29efe356fbea893781b73250dca51de3b6d47468d74027b0", "suite": "primitives.json", - "suite_sha256": "939969dd01cd520c8f9b41377d11980d5454f6161158a5326bb84c1874ef65dd", + "suite_sha256": "6d45ffb4ddcbf6f5c577c17bf2ccdeba296a159ae03ba50b9a2ee7f14b70a76a", "capture": { "device_scale_factor": 1, "omit_background": true, @@ -91,6 +91,42 @@ "width": 64, "height": 64 }, + { + "id": "html-opacity-source-host-high", + "source": "html-opacity-source-host-high.html", + "source_sha256": "22c993bdfb32cd5dfbe204eb37ec523f9d38d02d157a21cb06bd6b8d3589a154", + "oracle": "chromium/html-opacity-source-host-high.png", + "oracle_sha256": "59eb838d56f0e006b56b1bce84564f75ad352eaad3102c9308cf1f2eded9afb7", + "width": 64, + "height": 64 + }, + { + "id": "html-opacity-source-host-low", + "source": "html-opacity-source-host-low.html", + "source_sha256": "e34dfc2f9592e5d5e94588f99aa6440c9d5af1ddc50f8b33f7aeb03a6185f3a2", + "oracle": "chromium/html-opacity-source-host-low.png", + "oracle_sha256": "4faaea326a87947cc423889ea95ac14d07feba18a7a919f65d20049e3b1a53f2", + "width": 64, + "height": 64 + }, + { + "id": "html-opacity-source-root-high", + "source": "html-opacity-source-root-high.html", + "source_sha256": "2ef10d63219e10ed06c61363056b2cea0fbfc2a3f5451bf0b06cbae106f4fe1d", + "oracle": "chromium/html-opacity-source-root-high.png", + "oracle_sha256": "59eb838d56f0e006b56b1bce84564f75ad352eaad3102c9308cf1f2eded9afb7", + "width": 64, + "height": 64 + }, + { + "id": "html-opacity-source-root-low", + "source": "html-opacity-source-root-low.html", + "source_sha256": "a437dc1fa67374914f775982121ff4936c593889acc5c9e7344380fc3c1b1c0e", + "oracle": "chromium/html-opacity-source-root-low.png", + "oracle_sha256": "4faaea326a87947cc423889ea95ac14d07feba18a7a919f65d20049e3b1a53f2", + "width": 64, + "height": 64 + }, { "id": "html-webpage-mockup", "source": "html-webpage-mockup.html", @@ -8857,6 +8893,294 @@ "width": 64, "height": 64 }, + { + "id": "svg-opacity-source-blur-absent", + "source": "svg-opacity-source-blur-absent.svg", + "source_sha256": "7dc2353b6f54e46223fc565f9cf2255f2e576e35663fb5b269814ee3b0bff986", + "oracle": "chromium/svg-opacity-source-blur-absent.png", + "oracle_sha256": "8578566e641dd47d3e8a34ed754f6e58a8ae8cbc1cce1e191428ece1c54daac2", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-arc", + "source": "svg-opacity-source-blur-arc.svg", + "source_sha256": "0bf9a3406281d362f05026282221fb9d9416029b9ed923d0ec2ff6958a685650", + "oracle": "chromium/svg-opacity-source-blur-arc.png", + "oracle_sha256": "5d781e8dc2f645b2f51b6e2a1e118d3d6d7a8114ea20a2ab81beb617790ac71d", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-high", + "source": "svg-opacity-source-blur-high.svg", + "source_sha256": "13ef0ffd637c39634aafb490ad92ea3d3b63f0761f6be9c364d1dbef5c86654f", + "oracle": "chromium/svg-opacity-source-blur-high.png", + "oracle_sha256": "ca8265e2622176253634aa18b9cb6b06963085fb65b1b66779199a59a2a2ece7", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-low", + "source": "svg-opacity-source-blur-low.svg", + "source_sha256": "2c7031c1cfce30ee1f57e976f45be92eb61942f2e5b42fc47250c80f697b3571", + "oracle": "chromium/svg-opacity-source-blur-low.png", + "oracle_sha256": "9a8bfb34bcf72448d60e30bdbba21ea680743153c3d9d8df68d42a26b899f1fe", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-srgb", + "source": "svg-opacity-source-blur-srgb.svg", + "source_sha256": "8afc12b2a0e4ce637e7f0cdbfbcb93e43d41289353cfa28fc500ac027767e732", + "oracle": "chromium/svg-opacity-source-blur-srgb.png", + "oracle_sha256": "39aa54587fb7e768db70fd00bfc8a81c8d9860f50a64a3098be226576161f1b8", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-zero", + "source": "svg-opacity-source-blur-zero.svg", + "source_sha256": "c1043dfc8cc0331183b44835ebe78352be72454d60f5081dc4b63f16372de995", + "oracle": "chromium/svg-opacity-source-blur-zero.png", + "oracle_sha256": "4b76a039c0f921093588df5bd5e855d7d0aa06629248c281151dbb3147aaaaa3", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-ellipse-height", + "source": "svg-opacity-source-disabled-ellipse-height.svg", + "source_sha256": "c919ff8bdfbbc7a3601989579ac8ef202c6528317dedc26ffce2fdb23bc5e32a", + "oracle": "chromium/svg-opacity-source-disabled-ellipse-height.png", + "oracle_sha256": "db787e5cf67bcc813d3f4af2c7bfd3e9b8220c9cbf3305f69e0aa1bb2db88ca1", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-ellipse-width", + "source": "svg-opacity-source-disabled-ellipse-width.svg", + "source_sha256": "41a2cb789a8c787d6def094d4801ec7ce5ea07f6a0fe83e182c51300fd668e2f", + "oracle": "chromium/svg-opacity-source-disabled-ellipse-width.png", + "oracle_sha256": "db787e5cf67bcc813d3f4af2c7bfd3e9b8220c9cbf3305f69e0aa1bb2db88ca1", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-rect", + "source": "svg-opacity-source-disabled-rect.svg", + "source_sha256": "28b31efa58f361ae4f9f34ca59865e4501a5b5c1824bfbf94eac1f757e308b9b", + "oracle": "chromium/svg-opacity-source-disabled-rect.png", + "oracle_sha256": "db787e5cf67bcc813d3f4af2c7bfd3e9b8220c9cbf3305f69e0aa1bb2db88ca1", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-rect-height", + "source": "svg-opacity-source-disabled-rect-height.svg", + "source_sha256": "b46159fe3c4cbf166ca34120aad68f12402a062af8b0fcb7991b173a769d9467", + "oracle": "chromium/svg-opacity-source-disabled-rect-height.png", + "oracle_sha256": "db787e5cf67bcc813d3f4af2c7bfd3e9b8220c9cbf3305f69e0aa1bb2db88ca1", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-rect-width", + "source": "svg-opacity-source-disabled-rect-width.svg", + "source_sha256": "e2e0de3f89e99ca8f361a6c7d3b2436fc10d41890eb85e27b6a282ae916c7c11", + "oracle": "chromium/svg-opacity-source-disabled-rect-width.png", + "oracle_sha256": "db787e5cf67bcc813d3f4af2c7bfd3e9b8220c9cbf3305f69e0aa1bb2db88ca1", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-enclosing-clip", + "source": "svg-opacity-source-enclosing-clip.svg", + "source_sha256": "bdd2bfc4ede3a93a4f7bafd5a2c93cadb4eeff372c3e8f8423abe61c3030f2b4", + "oracle": "chromium/svg-opacity-source-enclosing-clip.png", + "oracle_sha256": "7ee2ae7c8d89f1c52fa20e53c85c305976d87799cd6ea7b8ab6beeda4c4bc7e2", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-leaf-stroke", + "source": "svg-opacity-source-leaf-stroke.svg", + "source_sha256": "5f3e4398e2fd06edd80899dbd2b451f9d19a76a531a2bcec57cb745a4cf8f0fa", + "oracle": "chromium/svg-opacity-source-leaf-stroke.png", + "oracle_sha256": "1745a3c17989711ed98deecd915988bec57f0e4c133cced10af7d6c0bfef8b5c", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-linear-simple", + "source": "svg-opacity-source-linear-simple.svg", + "source_sha256": "a696aaa001448663656c7e4887d9f5878bfbd21fdeb04b35a9ce7ded875f4cbf", + "oracle": "chromium/svg-opacity-source-linear-simple.png", + "oracle_sha256": "1229f80eba3cfe813a21599154456a434003743e202a96f0e2d974d1bdeb787a", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-absent", + "source": "svg-opacity-source-pattern-absent.svg", + "source_sha256": "c176537f5ae60721fb441cda70e82a53dcfe254b3006b69dc66ade976770697f", + "oracle": "chromium/svg-opacity-source-pattern-absent.png", + "oracle_sha256": "9b5de4210ece68b705153b1df4e852520a48457fa6267166dbb14907c54273e1", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-fractional", + "source": "svg-opacity-source-pattern-fractional.svg", + "source_sha256": "fc502ea9da3f5fddedb49efc57f8a3464e770ed0cde0993656559aae7996ba03", + "oracle": "chromium/svg-opacity-source-pattern-fractional.png", + "oracle_sha256": "2d9c7f114484e66909c03081e060b33aba6b12eaf4cc38ca849d9a993a1d9638", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-high", + "source": "svg-opacity-source-pattern-high.svg", + "source_sha256": "015ce0ad83bdc07dbc536d76ba9d75867b6e6a9d2ce65846f4d3eaead40ae783", + "oracle": "chromium/svg-opacity-source-pattern-high.png", + "oracle_sha256": "bcc2d082c4500ff2810e4f5b3f76d1b8fb268e69178b9b9af796d81569dc26bb", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-low", + "source": "svg-opacity-source-pattern-low.svg", + "source_sha256": "6f507d8cbf920300d7de6df474d58d43c8a3cb0270fa24d976b80b734c28822b", + "oracle": "chromium/svg-opacity-source-pattern-low.png", + "oracle_sha256": "58e26bfff7b9c5a6c58f9029baf5a1f0d5427b8050037de501a5285beb00ce6c", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-mirror", + "source": "svg-opacity-source-pattern-mirror.svg", + "source_sha256": "dde2cad34836ba44d7cf34a14ea85f12b627c2bfcb723d618721afcde11a4103", + "oracle": "chromium/svg-opacity-source-pattern-mirror.png", + "oracle_sha256": "0a1c518e7f541304d23e140f871dd156e08c54722db4f5c7880913082d4c7a95", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-stroke", + "source": "svg-opacity-source-pattern-stroke.svg", + "source_sha256": "81759ec0a666d76e17f7d7781cafb567b3da16b98db05035b16c4c54d5a16ddb", + "oracle": "chromium/svg-opacity-source-pattern-stroke.png", + "oracle_sha256": "96bc03a3fb59b363407d691fe518194c4d8d812cad0f44fa35e387059491a8a9", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-translated", + "source": "svg-opacity-source-pattern-translated.svg", + "source_sha256": "3188ad586980ed7e37a495cb9a57716be48ebe880371f5aa9eb43af4b2e2f400", + "oracle": "chromium/svg-opacity-source-pattern-translated.png", + "oracle_sha256": "e4a84d191bedcef3d906a76a910ceb32317f9c4a33f417b8cafdc4a0f6e40102", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-absent", + "source": "svg-opacity-source-radial-absent.svg", + "source_sha256": "d05a3c7c716b195c52835e2cb74c1300e28a4b0c362c5b680f42a8db702eb44e", + "oracle": "chromium/svg-opacity-source-radial-absent.png", + "oracle_sha256": "9b13e1227d3d03a54b831e8102f967062deed80ac24de3a7da755771b895175d", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-fractional", + "source": "svg-opacity-source-radial-fractional.svg", + "source_sha256": "3a01e46db912d90b8f76d5e0529e2556ce85b18558db50961db592bd333ee485", + "oracle": "chromium/svg-opacity-source-radial-fractional.png", + "oracle_sha256": "c87720fbc2262e633409f5b89fe70bd0ba660976614b3c4539330df54cf3b15a", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-high", + "source": "svg-opacity-source-radial-high.svg", + "source_sha256": "5c033c6c331560e458baf77431779f219d952528508ba9245a2681bc67a4151e", + "oracle": "chromium/svg-opacity-source-radial-high.png", + "oracle_sha256": "66a0511bdc15a8d2c743d145c9910b3a8777c228e36f4b84111a39a5acdaa305", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-low", + "source": "svg-opacity-source-radial-low.svg", + "source_sha256": "3438c3e447d6259e55f69d4712a798a674b7f849871fb8cdf9e53ee3da1e67ce", + "oracle": "chromium/svg-opacity-source-radial-low.png", + "oracle_sha256": "e527d69274043c8a5021d22fcd8af5b7b51aef335045e68c1fa5e2dd5eea5d99", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-mirror", + "source": "svg-opacity-source-radial-mirror.svg", + "source_sha256": "c84b71ec1842fba46134e7c9f6a9fc60999301710643ba4c20346ae1667f938a", + "oracle": "chromium/svg-opacity-source-radial-mirror.png", + "oracle_sha256": "f6e3e3b12c188ed30b4418fcaf9feb8b2020dc613837f59924c7677673888818", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-simple", + "source": "svg-opacity-source-radial-simple.svg", + "source_sha256": "b4650fe499c9005225823a2fdd5658ed27ac8885d347e71fa662b91420ead840", + "oracle": "chromium/svg-opacity-source-radial-simple.png", + "oracle_sha256": "db787e5cf67bcc813d3f4af2c7bfd3e9b8220c9cbf3305f69e0aa1bb2db88ca1", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-stroke", + "source": "svg-opacity-source-radial-stroke.svg", + "source_sha256": "2d022f1c6b5e2fbdbff124fba7fa758823b8f9a8c0c2e52c8acf6ea7691d2ce5", + "oracle": "chromium/svg-opacity-source-radial-stroke.png", + "oracle_sha256": "22c37e9303215b0759b0ec0199b139f9d58a6b02d6a13f9f31a771e2841d8aec", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-translated", + "source": "svg-opacity-source-radial-translated.svg", + "source_sha256": "7156308458cce2d7e4afa1d0b1dff8c7a37aaea86529b84e07d37e1235da117a", + "oracle": "chromium/svg-opacity-source-radial-translated.png", + "oracle_sha256": "552b54489d803a896aade3954fad8770fefac2a7cf19fc4f8ad56e04d87998bc", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-uniform", + "source": "svg-opacity-source-radial-uniform.svg", + "source_sha256": "3c84409848adafd35fb2012bed7e3e71ed338e2bb802f996ac92a00ae613c6ac", + "oracle": "chromium/svg-opacity-source-radial-uniform.png", + "oracle_sha256": "7170d59fb1cbeea4c4a5e3635326f0345bbc066182901b4a914142746ae604d5", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-root-transparent", + "source": "svg-opacity-source-root-transparent.svg", + "source_sha256": "5fa8af19092c2d62c079b134a4264263f336d3efd15e829ab46363928afc98dd", + "oracle": "chromium/svg-opacity-source-root-transparent.png", + "oracle_sha256": "59eb838d56f0e006b56b1bce84564f75ad352eaad3102c9308cf1f2eded9afb7", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-use", + "source": "svg-opacity-source-use.svg", + "source_sha256": "b928c1441e6e9eb9e4d92e6f951ff05e2aee9ab75637b24b7f9b206a5bb4a265", + "oracle": "chromium/svg-opacity-source-use.png", + "oracle_sha256": "db787e5cf67bcc813d3f4af2c7bfd3e9b8220c9cbf3305f69e0aa1bb2db88ca1", + "width": 64, + "height": 64 + }, { "id": "svg-opacity-stroke-only-fold", "source": "svg-opacity-stroke-only-fold.svg", diff --git a/fixtures/web-first/primitives.json b/fixtures/web-first/primitives.json index bce961ce..616f87cf 100644 --- a/fixtures/web-first/primitives.json +++ b/fixtures/web-first/primitives.json @@ -65,6 +65,38 @@ "width": 64, "height": 64 }, + { + "id": "html-opacity-source-host-high", + "source": "html-opacity-source-host-high.html", + "entry": "html-inline-svg", + "oracle": "chromium/html-opacity-source-host-high.png", + "width": 64, + "height": 64 + }, + { + "id": "html-opacity-source-host-low", + "source": "html-opacity-source-host-low.html", + "entry": "html-inline-svg", + "oracle": "chromium/html-opacity-source-host-low.png", + "width": 64, + "height": 64 + }, + { + "id": "html-opacity-source-root-high", + "source": "html-opacity-source-root-high.html", + "entry": "html-inline-svg", + "oracle": "chromium/html-opacity-source-root-high.png", + "width": 64, + "height": 64 + }, + { + "id": "html-opacity-source-root-low", + "source": "html-opacity-source-root-low.html", + "entry": "html-inline-svg", + "oracle": "chromium/html-opacity-source-root-low.png", + "width": 64, + "height": 64 + }, { "id": "html-webpage-mockup", "source": "html-webpage-mockup.html", @@ -7955,6 +7987,262 @@ "width": 64, "height": 64 }, + { + "id": "svg-opacity-source-blur-absent", + "source": "svg-opacity-source-blur-absent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-blur-absent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-arc", + "source": "svg-opacity-source-blur-arc.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-blur-arc.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-high", + "source": "svg-opacity-source-blur-high.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-blur-high.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-low", + "source": "svg-opacity-source-blur-low.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-blur-low.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-srgb", + "source": "svg-opacity-source-blur-srgb.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-blur-srgb.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-blur-zero", + "source": "svg-opacity-source-blur-zero.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-blur-zero.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-ellipse-height", + "source": "svg-opacity-source-disabled-ellipse-height.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-disabled-ellipse-height.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-ellipse-width", + "source": "svg-opacity-source-disabled-ellipse-width.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-disabled-ellipse-width.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-rect", + "source": "svg-opacity-source-disabled-rect.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-disabled-rect.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-rect-height", + "source": "svg-opacity-source-disabled-rect-height.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-disabled-rect-height.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-disabled-rect-width", + "source": "svg-opacity-source-disabled-rect-width.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-disabled-rect-width.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-enclosing-clip", + "source": "svg-opacity-source-enclosing-clip.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-enclosing-clip.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-leaf-stroke", + "source": "svg-opacity-source-leaf-stroke.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-leaf-stroke.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-linear-simple", + "source": "svg-opacity-source-linear-simple.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-linear-simple.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-absent", + "source": "svg-opacity-source-pattern-absent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-pattern-absent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-fractional", + "source": "svg-opacity-source-pattern-fractional.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-pattern-fractional.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-high", + "source": "svg-opacity-source-pattern-high.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-pattern-high.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-low", + "source": "svg-opacity-source-pattern-low.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-pattern-low.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-mirror", + "source": "svg-opacity-source-pattern-mirror.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-pattern-mirror.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-stroke", + "source": "svg-opacity-source-pattern-stroke.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-pattern-stroke.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-pattern-translated", + "source": "svg-opacity-source-pattern-translated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-pattern-translated.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-absent", + "source": "svg-opacity-source-radial-absent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-radial-absent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-fractional", + "source": "svg-opacity-source-radial-fractional.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-radial-fractional.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-high", + "source": "svg-opacity-source-radial-high.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-radial-high.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-low", + "source": "svg-opacity-source-radial-low.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-radial-low.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-mirror", + "source": "svg-opacity-source-radial-mirror.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-radial-mirror.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-simple", + "source": "svg-opacity-source-radial-simple.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-radial-simple.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-stroke", + "source": "svg-opacity-source-radial-stroke.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-radial-stroke.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-translated", + "source": "svg-opacity-source-radial-translated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-radial-translated.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-radial-uniform", + "source": "svg-opacity-source-radial-uniform.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-radial-uniform.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-root-transparent", + "source": "svg-opacity-source-root-transparent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-root-transparent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-opacity-source-use", + "source": "svg-opacity-source-use.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-opacity-source-use.png", + "width": 64, + "height": 64 + }, { "id": "svg-opacity-stroke-only-fold", "source": "svg-opacity-stroke-only-fold.svg", diff --git a/fixtures/web-first/svg-opacity-source-blur-absent.svg b/fixtures/web-first/svg-opacity-source-blur-absent.svg new file mode 100644 index 00000000..58ddda85 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-blur-absent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-blur-arc.svg b/fixtures/web-first/svg-opacity-source-blur-arc.svg new file mode 100644 index 00000000..4e5692b9 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-blur-arc.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-blur-high.svg b/fixtures/web-first/svg-opacity-source-blur-high.svg new file mode 100644 index 00000000..49e1eabc --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-blur-high.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-blur-low.svg b/fixtures/web-first/svg-opacity-source-blur-low.svg new file mode 100644 index 00000000..ce4456d8 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-blur-low.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-blur-srgb.svg b/fixtures/web-first/svg-opacity-source-blur-srgb.svg new file mode 100644 index 00000000..34a826fc --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-blur-srgb.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-blur-zero.svg b/fixtures/web-first/svg-opacity-source-blur-zero.svg new file mode 100644 index 00000000..a7ea91c1 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-blur-zero.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-disabled-ellipse-height.svg b/fixtures/web-first/svg-opacity-source-disabled-ellipse-height.svg new file mode 100644 index 00000000..0d83d293 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-disabled-ellipse-height.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-disabled-ellipse-width.svg b/fixtures/web-first/svg-opacity-source-disabled-ellipse-width.svg new file mode 100644 index 00000000..c0d1e275 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-disabled-ellipse-width.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-disabled-rect-height.svg b/fixtures/web-first/svg-opacity-source-disabled-rect-height.svg new file mode 100644 index 00000000..ab04da60 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-disabled-rect-height.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-disabled-rect-width.svg b/fixtures/web-first/svg-opacity-source-disabled-rect-width.svg new file mode 100644 index 00000000..dfb5179f --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-disabled-rect-width.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-disabled-rect.svg b/fixtures/web-first/svg-opacity-source-disabled-rect.svg new file mode 100644 index 00000000..eaf7c28b --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-disabled-rect.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-enclosing-clip.svg b/fixtures/web-first/svg-opacity-source-enclosing-clip.svg new file mode 100644 index 00000000..146c9c97 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-enclosing-clip.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-leaf-stroke.svg b/fixtures/web-first/svg-opacity-source-leaf-stroke.svg new file mode 100644 index 00000000..db68609b --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-leaf-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-linear-simple.svg b/fixtures/web-first/svg-opacity-source-linear-simple.svg new file mode 100644 index 00000000..c9041008 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-linear-simple.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-pattern-absent.svg b/fixtures/web-first/svg-opacity-source-pattern-absent.svg new file mode 100644 index 00000000..f7c75062 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-pattern-absent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-pattern-fractional.svg b/fixtures/web-first/svg-opacity-source-pattern-fractional.svg new file mode 100644 index 00000000..f870bd65 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-pattern-fractional.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-pattern-high.svg b/fixtures/web-first/svg-opacity-source-pattern-high.svg new file mode 100644 index 00000000..ffdb2c5c --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-pattern-high.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-pattern-low.svg b/fixtures/web-first/svg-opacity-source-pattern-low.svg new file mode 100644 index 00000000..2966c042 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-pattern-low.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-pattern-mirror.svg b/fixtures/web-first/svg-opacity-source-pattern-mirror.svg new file mode 100644 index 00000000..c1938390 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-pattern-mirror.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-pattern-stroke.svg b/fixtures/web-first/svg-opacity-source-pattern-stroke.svg new file mode 100644 index 00000000..3b0ed3df --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-pattern-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-pattern-translated.svg b/fixtures/web-first/svg-opacity-source-pattern-translated.svg new file mode 100644 index 00000000..f2578b11 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-pattern-translated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-radial-absent.svg b/fixtures/web-first/svg-opacity-source-radial-absent.svg new file mode 100644 index 00000000..c8b89b4e --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-radial-absent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-radial-fractional.svg b/fixtures/web-first/svg-opacity-source-radial-fractional.svg new file mode 100644 index 00000000..0076b427 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-radial-fractional.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-radial-high.svg b/fixtures/web-first/svg-opacity-source-radial-high.svg new file mode 100644 index 00000000..e3681cd4 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-radial-high.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-radial-low.svg b/fixtures/web-first/svg-opacity-source-radial-low.svg new file mode 100644 index 00000000..faf1ef31 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-radial-low.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-radial-mirror.svg b/fixtures/web-first/svg-opacity-source-radial-mirror.svg new file mode 100644 index 00000000..5ee78944 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-radial-mirror.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-radial-simple.svg b/fixtures/web-first/svg-opacity-source-radial-simple.svg new file mode 100644 index 00000000..43211099 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-radial-simple.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-radial-stroke.svg b/fixtures/web-first/svg-opacity-source-radial-stroke.svg new file mode 100644 index 00000000..767c0f08 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-radial-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-radial-translated.svg b/fixtures/web-first/svg-opacity-source-radial-translated.svg new file mode 100644 index 00000000..f418c481 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-radial-translated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-radial-uniform.svg b/fixtures/web-first/svg-opacity-source-radial-uniform.svg new file mode 100644 index 00000000..388429da --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-radial-uniform.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-root-transparent.svg b/fixtures/web-first/svg-opacity-source-root-transparent.svg new file mode 100644 index 00000000..01f24b4d --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-root-transparent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-opacity-source-use.svg b/fixtures/web-first/svg-opacity-source-use.svg new file mode 100644 index 00000000..9fd43f43 --- /dev/null +++ b/fixtures/web-first/svg-opacity-source-use.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/README.md b/fixtures/web-first/unsupported/README.md index d09f6d5d..4d4c4184 100644 --- a/fixtures/web-first/unsupported/README.md +++ b/fixtures/web-first/unsupported/README.md @@ -21,7 +21,7 @@ The scannable, generated view of this register (beside the baked cells) is [../STATUS.md](../STATUS.md), freshness-gated by `crates/websem/tests/capability_status.rs`. -The inventory contains 351 named refusal sources, including twenty-two +The inventory contains 368 named refusal sources, including twenty-two sibling-review witnesses. The [sibling-extent follow-on](../../../docs/wg/consolidation/svg-engine-of-record.md#b2b-sibling-extent-follow-on) preserves four former refusal witnesses as positive cells: `transparent`, @@ -33,6 +33,12 @@ memberships are removed. | File | Required result | | --- | --- | +| `svg-opacity-source-child-transform.svg` · `svg-opacity-source-shear.svg` · `svg-opacity-source-unequal-scale.svg` | Bound opacity source mapping: contributors must share the group map, which currently carries rotation/reflection, uniform scale and translation. Shear's opacity-only precision departure is quarantined; the independently observed no-opacity unequal-scale ellipse departure is not repaired by this rung. | +| `svg-opacity-source-path.svg` · `svg-opacity-source-root-path.svg` · `svg-opacity-source-complex-stroke.svg` | Refuse source enclosures for paths or complex strokes. Root ownership refuses in both admissions; an attributable group is skipped as a whole. Geometry boxes are not substituted for a complete drawable source. | +| `svg-opacity-source-omitted-stroke.svg` · `svg-opacity-source-transparent-contributor.svg` · `svg-opacity-source-nested-contributor.svg` · `svg-opacity-source-clipped-contributor.svg` | Refuse when omitted paint or nested effects make the bare source incomplete. No partial child image escapes the rejected owner. | +| `svg-opacity-source-enclosing-filter.svg` · `svg-opacity-source-enclosing-mask.svg` | Refuse an enclosing image effect around a newly declared opacity source domain until that separate composition profile is supported. An enclosing geometric clip is separately celled as admitted. | +| `svg-opacity-source-pattern-program.svg` · `svg-opacity-source-mask-program.svg` · `svg-opacity-source-marker-client.svg` | Bound resource-program and combined marker-client sources. A gradient-painted marker client differed by 1,408 pixels at maximum delta 1 before its patrol (measured, not celled); the complete client now skips by name. The mask-program witness uses `.57`, whose Chromium image differs from opacity one by 2,344 pixels at maximum delta 65 (measured, not celled). | +| `svg-paint-server-source-filter.svg` · `svg-paint-server-source-mask.svg` | Refuse a multi-operation bare paint-server source feeding an image filter or mask, even without partial element opacity. The measured pre-patrol differences were 297 pixels at maximum delta 2 and 923 at delta 1 respectively (measured, not celled). Both admissions name the source profile; best effort skips the complete group. | | `svg-group-blend-sibling-domain-{mapped,circle,non-scaling,opacity}.svg` | Four boundaries around the unit-opacity, identity-mapped rectangle profile: a mapped sibling, nonrectangular paintless geometry, an invisible non-scaling stroke and zero element opacity must retain the named `linear-gradient source-extent` refusal. | | `svg-group-blend-sibling-review-{leaf,group}-{offset,blur,mask,clip}.svg` | Eight review witnesses for effects on a paintless sibling or its enclosing container inside the blend source. Name the source-extent boundary and skip the affected group; an effect's completed contribution cannot be inferred from the surviving paintless rectangle. These required refusals make no unmeasured per-witness pixel claim. | | `svg-group-blend-sibling-review-{nested-clipped,nested-visible}.svg` | Two nested-viewport review witnesses, with default clipping and explicit visible overflow. Retain the named source-extent boundary; neither nesting route inherits the neutral-container exemption. | diff --git a/fixtures/web-first/unsupported/svg-opacity-source-child-transform.svg b/fixtures/web-first/unsupported/svg-opacity-source-child-transform.svg new file mode 100644 index 00000000..0d1312a8 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-child-transform.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-clipped-contributor.svg b/fixtures/web-first/unsupported/svg-opacity-source-clipped-contributor.svg new file mode 100644 index 00000000..8494ba00 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-clipped-contributor.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-complex-stroke.svg b/fixtures/web-first/unsupported/svg-opacity-source-complex-stroke.svg new file mode 100644 index 00000000..666265f6 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-complex-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-enclosing-filter.svg b/fixtures/web-first/unsupported/svg-opacity-source-enclosing-filter.svg new file mode 100644 index 00000000..546789a3 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-enclosing-filter.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-enclosing-mask.svg b/fixtures/web-first/unsupported/svg-opacity-source-enclosing-mask.svg new file mode 100644 index 00000000..4a9adf18 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-enclosing-mask.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-marker-client.svg b/fixtures/web-first/unsupported/svg-opacity-source-marker-client.svg new file mode 100644 index 00000000..4a0e4009 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-marker-client.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-mask-program.svg b/fixtures/web-first/unsupported/svg-opacity-source-mask-program.svg new file mode 100644 index 00000000..fe6777eb --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-mask-program.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-nested-contributor.svg b/fixtures/web-first/unsupported/svg-opacity-source-nested-contributor.svg new file mode 100644 index 00000000..5bbc4ee1 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-nested-contributor.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-omitted-stroke.svg b/fixtures/web-first/unsupported/svg-opacity-source-omitted-stroke.svg new file mode 100644 index 00000000..b4be85a1 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-omitted-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-path.svg b/fixtures/web-first/unsupported/svg-opacity-source-path.svg new file mode 100644 index 00000000..b0b9391e --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-path.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-pattern-program.svg b/fixtures/web-first/unsupported/svg-opacity-source-pattern-program.svg new file mode 100644 index 00000000..f88433d2 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-pattern-program.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-root-path.svg b/fixtures/web-first/unsupported/svg-opacity-source-root-path.svg new file mode 100644 index 00000000..56abf9f6 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-root-path.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-shear.svg b/fixtures/web-first/unsupported/svg-opacity-source-shear.svg new file mode 100644 index 00000000..a37fdbc7 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-shear.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-transparent-contributor.svg b/fixtures/web-first/unsupported/svg-opacity-source-transparent-contributor.svg new file mode 100644 index 00000000..1556668a --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-transparent-contributor.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-opacity-source-unequal-scale.svg b/fixtures/web-first/unsupported/svg-opacity-source-unequal-scale.svg new file mode 100644 index 00000000..f8363565 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-opacity-source-unequal-scale.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-paint-server-source-filter.svg b/fixtures/web-first/unsupported/svg-paint-server-source-filter.svg new file mode 100644 index 00000000..c02e4069 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-paint-server-source-filter.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-paint-server-source-mask.svg b/fixtures/web-first/unsupported/svg-paint-server-source-mask.svg new file mode 100644 index 00000000..fd5a4d6e --- /dev/null +++ b/fixtures/web-first/unsupported/svg-paint-server-source-mask.svg @@ -0,0 +1 @@ + diff --git a/packages/grida-reftest/svg-assertions/README.md b/packages/grida-reftest/svg-assertions/README.md index c5050c3f..c0b29d00 100644 --- a/packages/grida-reftest/svg-assertions/README.md +++ b/packages/grida-reftest/svg-assertions/README.md @@ -47,6 +47,24 @@ own committed Chromium reference, not the other color's image. The refusal witness checks the exact current CLI declarations for geometry units; a pass there is labelled **expected refusal**, never rendering support. +## Ordinary-opacity regression suite + +[opacity-source.json](./opacity-source.json) applies the same instrument to +six described rendering claims and two expected refusals from +[gridaco/nothing#136](https://github.com/gridaco/nothing/issues/136). +The blur, repeating-pattern and radial-gradient scenes each pair group +opacity `.999` with `.998`: a one-pixel discrepancy still fails the complete +scene, and each control must change Chromium's pixels. Sheared group and +root-path cases check named refusals separately, not fallback-image accuracy. + +```sh +just -f fixtures/web-first/justfile assertions-opacity-gate "$PWD/target/svg-assertion-opacity" +``` + +This uses a fresh output directory under the same setup and immutable-output +rules as the pilot. CI runs both suites and retains both reports. No resvg +reference, new tolerance, or claim of complete opacity support is introduced. + ## A case is not its filename ```text diff --git a/packages/grida-reftest/svg-assertions/opacity-source.json b/packages/grida-reftest/svg-assertions/opacity-source.json new file mode 100644 index 00000000..df071236 --- /dev/null +++ b/packages/grida-reftest/svg-assertions/opacity-source.json @@ -0,0 +1,242 @@ +{ + "schema_version": 1, + "profile": "static-self-contained-svg-v1", + "capture": { + "sha256": "069296201718c43d29efe356fbea893781b73250dca51de3b6d47468d74027b0", + "browser_version": "149.0.7827.55" + }, + "cases": [ + { + "id": "svg-opacity-source-blur-high", + "source": { + "path": "../../../fixtures/web-first/svg-opacity-source-blur-high.svg", + "sha256": "13ef0ffd637c39634aafb490ad92ea3d3b63f0761f6be9c364d1dbef5c86654f" + }, + "description": "This 64×64 blur scene at group opacity .999 matches its declared Chromium raster. The native circle is materialized before the active Gaussian blur. This asserts the scene, not the complete opacity grammar.", + "required": true, + "width": 64, + "height": 64, + "review": { + "status": "reviewed", + "reason": "Static self-contained source without text or external resources; registered and baked through the Web-first fixture workflow. The paired control changes only the group opacity across the byte-alpha boundary.", + "blockers": [] + }, + "assertion": { + "kind": "render-exact", + "reference": { + "path": "../../../fixtures/web-first/chromium/svg-opacity-source-blur-high.png", + "sha256": "ca8265e2622176253634aa18b9cb6b06963085fb65b1b66779199a59a2a2ece7" + }, + "decision": "Pinned Chromium compatibility; exact decoded RGBA, no alternative oracle or tolerance.", + "control": { + "case": "svg-opacity-source-blur-low", + "why": "Changing only .999 to .998 changes 2835 Chromium pixels (maximum channel delta 2); both sides must independently match their own raster." + } + }, + "stored": null + }, + { + "id": "svg-opacity-source-blur-low", + "source": { + "path": "../../../fixtures/web-first/svg-opacity-source-blur-low.svg", + "sha256": "2c7031c1cfce30ee1f57e976f45be92eb61942f2e5b42fc47250c80f697b3571" + }, + "description": "This 64×64 blur scene at group opacity .998 matches its declared Chromium raster. The native circle is materialized before the active Gaussian blur. This asserts the scene, not the complete opacity grammar.", + "required": true, + "width": 64, + "height": 64, + "review": { + "status": "reviewed", + "reason": "Static self-contained source without text or external resources; registered and baked through the Web-first fixture workflow. The paired control changes only the group opacity across the byte-alpha boundary.", + "blockers": [] + }, + "assertion": { + "kind": "render-exact", + "reference": { + "path": "../../../fixtures/web-first/chromium/svg-opacity-source-blur-low.png", + "sha256": "9a8bfb34bcf72448d60e30bdbba21ea680743153c3d9d8df68d42a26b899f1fe" + }, + "decision": "Pinned Chromium compatibility; exact decoded RGBA, no alternative oracle or tolerance.", + "control": { + "case": "svg-opacity-source-blur-high", + "why": "Changing only .999 to .998 changes 2835 Chromium pixels (maximum channel delta 2); both sides must independently match their own raster." + } + }, + "stored": null + }, + { + "id": "svg-opacity-source-pattern-high", + "source": { + "path": "../../../fixtures/web-first/svg-opacity-source-pattern-high.svg", + "sha256": "015ce0ad83bdc07dbc536d76ba9d75867b6e6a9d2ce65846f4d3eaead40ae783" + }, + "description": "This 64×64 pattern scene at group opacity .999 matches its declared Chromium raster. The multi-operation paint-server source retains its complete local drawable enclosure. This asserts the scene, not the complete opacity grammar.", + "required": true, + "width": 64, + "height": 64, + "review": { + "status": "reviewed", + "reason": "Static self-contained source without text or external resources; registered and baked through the Web-first fixture workflow. The paired control changes only the group opacity across the byte-alpha boundary.", + "blockers": [] + }, + "assertion": { + "kind": "render-exact", + "reference": { + "path": "../../../fixtures/web-first/chromium/svg-opacity-source-pattern-high.png", + "sha256": "bcc2d082c4500ff2810e4f5b3f76d1b8fb268e69178b9b9af796d81569dc26bb" + }, + "decision": "Pinned Chromium compatibility; exact decoded RGBA, no alternative oracle or tolerance.", + "control": { + "case": "svg-opacity-source-pattern-low", + "why": "Changing only .999 to .998 changes 2020 Chromium pixels (maximum channel delta 1); both sides must independently match their own raster." + } + }, + "stored": null + }, + { + "id": "svg-opacity-source-pattern-low", + "source": { + "path": "../../../fixtures/web-first/svg-opacity-source-pattern-low.svg", + "sha256": "6f507d8cbf920300d7de6df474d58d43c8a3cb0270fa24d976b80b734c28822b" + }, + "description": "This 64×64 pattern scene at group opacity .998 matches its declared Chromium raster. The multi-operation paint-server source retains its complete local drawable enclosure. This asserts the scene, not the complete opacity grammar.", + "required": true, + "width": 64, + "height": 64, + "review": { + "status": "reviewed", + "reason": "Static self-contained source without text or external resources; registered and baked through the Web-first fixture workflow. The paired control changes only the group opacity across the byte-alpha boundary.", + "blockers": [] + }, + "assertion": { + "kind": "render-exact", + "reference": { + "path": "../../../fixtures/web-first/chromium/svg-opacity-source-pattern-low.png", + "sha256": "58e26bfff7b9c5a6c58f9029baf5a1f0d5427b8050037de501a5285beb00ce6c" + }, + "decision": "Pinned Chromium compatibility; exact decoded RGBA, no alternative oracle or tolerance.", + "control": { + "case": "svg-opacity-source-pattern-high", + "why": "Changing only .999 to .998 changes 2020 Chromium pixels (maximum channel delta 1); both sides must independently match their own raster." + } + }, + "stored": null + }, + { + "id": "svg-opacity-source-radial-high", + "source": { + "path": "../../../fixtures/web-first/svg-opacity-source-radial-high.svg", + "sha256": "5c033c6c331560e458baf77431779f219d952528508ba9245a2681bc67a4151e" + }, + "description": "This 64×64 radial scene at group opacity .999 matches its declared Chromium raster. The multi-operation paint-server source retains its complete local drawable enclosure. This asserts the scene, not the complete opacity grammar.", + "required": true, + "width": 64, + "height": 64, + "review": { + "status": "reviewed", + "reason": "Static self-contained source without text or external resources; registered and baked through the Web-first fixture workflow. The paired control changes only the group opacity across the byte-alpha boundary.", + "blockers": [] + }, + "assertion": { + "kind": "render-exact", + "reference": { + "path": "../../../fixtures/web-first/chromium/svg-opacity-source-radial-high.png", + "sha256": "66a0511bdc15a8d2c743d145c9910b3a8777c228e36f4b84111a39a5acdaa305" + }, + "decision": "Pinned Chromium compatibility; exact decoded RGBA, no alternative oracle or tolerance.", + "control": { + "case": "svg-opacity-source-radial-low", + "why": "Changing only .999 to .998 changes 1969 Chromium pixels (maximum channel delta 1); both sides must independently match their own raster." + } + }, + "stored": null + }, + { + "id": "svg-opacity-source-radial-low", + "source": { + "path": "../../../fixtures/web-first/svg-opacity-source-radial-low.svg", + "sha256": "3438c3e447d6259e55f69d4712a798a674b7f849871fb8cdf9e53ee3da1e67ce" + }, + "description": "This 64×64 radial scene at group opacity .998 matches its declared Chromium raster. The multi-operation paint-server source retains its complete local drawable enclosure. This asserts the scene, not the complete opacity grammar.", + "required": true, + "width": 64, + "height": 64, + "review": { + "status": "reviewed", + "reason": "Static self-contained source without text or external resources; registered and baked through the Web-first fixture workflow. The paired control changes only the group opacity across the byte-alpha boundary.", + "blockers": [] + }, + "assertion": { + "kind": "render-exact", + "reference": { + "path": "../../../fixtures/web-first/chromium/svg-opacity-source-radial-low.png", + "sha256": "e527d69274043c8a5021d22fcd8af5b7b51aef335045e68c1fa5e2dd5eea5d99" + }, + "decision": "Pinned Chromium compatibility; exact decoded RGBA, no alternative oracle or tolerance.", + "control": { + "case": "svg-opacity-source-radial-high", + "why": "Changing only .999 to .998 changes 1969 Chromium pixels (maximum channel delta 1); both sides must independently match their own raster." + } + }, + "stored": null + }, + { + "id": "svg-opacity-source-shear", + "source": { + "path": "../../../fixtures/web-first/unsupported/svg-opacity-source-shear.svg", + "sha256": "7dffe09b862c4b5a31cb87caf7922dd6764c8158a27499518065cfb0c380b0da" + }, + "description": "A sheared multi-operation gradient source refuses at its complete group; best effort names and skips that group. This is expected refusal, not rendered support.", + "required": true, + "width": 64, + "height": 64, + "review": { + "status": "reviewed", + "reason": "Registered static self-contained refusal witness. Exact diagnostics and stable group attribution were measured through both actual CLI admissions, twice.", + "blockers": [] + }, + "assertion": { + "kind": "refusal", + "decision": "Guard named degradation independently from positive pixel comparisons; fallback pixels do not certify rendering support.", + "strict": { + "exit": 1, + "diagnostics": "error: render failed: unsupported computed style: opacity source-extent needs its shear or unequal-scale source mapping profile" + }, + "best": { + "exit": 0, + "diagnostics": "degraded: skipped svg/g[1]: unsupported computed style: opacity source-extent needs its shear or unequal-scale source mapping profile" + } + }, + "stored": null + }, + { + "id": "svg-opacity-source-root-path", + "source": { + "path": "../../../fixtures/web-first/unsupported/svg-opacity-source-root-path.svg", + "sha256": "b06147e4334d8f036d1cd843dca1dd93717b214eb9143c1eed1b29e9d85cc292" + }, + "description": "A root opacity source containing a gradient path refuses at document level in both admissions. This is expected refusal, not rendered support.", + "required": true, + "width": 64, + "height": 64, + "review": { + "status": "reviewed", + "reason": "Registered static self-contained refusal witness. Exact diagnostics and stable group attribution were measured through both actual CLI admissions, twice.", + "blockers": [] + }, + "assertion": { + "kind": "refusal", + "decision": "Guard named degradation independently from positive pixel comparisons; fallback pixels do not certify rendering support.", + "strict": { + "exit": 1, + "diagnostics": "error: render failed: unsupported computed style: opacity source-extent needs its path-source enclosure profile" + }, + "best": { + "exit": 1, + "diagnostics": "error: render failed: unsupported computed style: opacity source-extent needs its path-source enclosure profile" + } + }, + "stored": null + } + ] +} From e7dd281c35691bad00169d5448fd70891d069dce Mon Sep 17 00:00:00 2001 From: Universe Date: Fri, 18 Sep 2026 15:14:35 +0900 Subject: [PATCH 2/3] test: retain opacity failure and Chromium runtime identities --- .github/workflows/consolidation-gates.yml | 42 +++++++++ .github/workflows/test-crates.yml | 10 +++ crates/websem/tests/reftest_oracle.rs | 24 ++++++ docs/wg/consolidation/svg-engine-of-record.md | 24 +++++- fixtures/web-first/README.md | 2 +- .../grida-reftest/svg-assertions/README.md | 17 +++- .../svg-assertions/capture-identity.test.ts | 21 +++++ .../svg-assertions/capture-identity.ts | 85 +++++++++++++++++++ .../svg-assertions/capture-worker.ts | 3 + .../svg-assertions/opacity-source.json | 29 +++++++ .../grida-reftest/svg-assertions/runner.ts | 1 + 11 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 packages/grida-reftest/svg-assertions/capture-identity.test.ts create mode 100644 packages/grida-reftest/svg-assertions/capture-identity.ts diff --git a/.github/workflows/consolidation-gates.yml b/.github/workflows/consolidation-gates.yml index 6d73d7ff..cbea8bad 100644 --- a/.github/workflows/consolidation-gates.yml +++ b/.github/workflows/consolidation-gates.yml @@ -211,6 +211,48 @@ jobs: if-no-files-found: warn retention-days: 7 + svg_arm_capture_candidate: + # Attestation candidate, not a replacement for the Linux engine gates. + # Until its exact identity is reviewed/pinned, this job certifies no gate. + name: SVG ARM capture candidate + needs: scope + if: needs.scope.outputs.engine == 'true' + runs-on: macos-26 + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.92.0 + - uses: Swatinem/rust-cache@v2 + with: + shared-key: svg-arm-capture + cache-on-failure: true + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - name: Install capture and build dependencies + run: | + brew install ninja + pnpm install --frozen-lockfile + pnpm -C packages/grida-reftest exec playwright install chromium + - name: Observe immutable opacity references and retain browser identity + run: | + pnpm -C packages/grida-reftest exec tsx svg-assertions/cli.ts \ + --manifest "$GITHUB_WORKSPACE/packages/grida-reftest/svg-assertions/opacity-source.json" \ + --out "$RUNNER_TEMP/svg-arm-capture" --observe + - uses: actions/upload-artifact@v4 + if: always() + with: + name: svg-arm-capture-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/svg-arm-capture + if-no-files-found: error + retention-days: 14 + legacy_pixel_sweep: name: legacy pixel sweep needs: scope diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index 8b31bbae..df346466 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -148,4 +148,14 @@ jobs: - name: Run cargo tests env: FORCE_SKIA_BINARIES_DOWNLOAD: "1" + N0_REFTEST_ARTIFACTS: ${{ runner.temp }}/n0-reftest-${{ github.run_id }}-${{ github.run_attempt }} run: cargo test --workspace --exclude grida-canvas-wasm + + - name: Preserve exact-reference failure diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: n0-reftest-failures-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/n0-reftest-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: ignore + retention-days: 14 diff --git a/crates/websem/tests/reftest_oracle.rs b/crates/websem/tests/reftest_oracle.rs index 4f08b182..836bc59c 100644 --- a/crates/websem/tests/reftest_oracle.rs +++ b/crates/websem/tests/reftest_oracle.rs @@ -24,6 +24,7 @@ mod support; use std::collections::BTreeSet; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use n0::paint::PaintCtx; @@ -285,6 +286,12 @@ fn primitive_oracle_provenance_is_current() { #[test] fn every_primitive_is_pixel_exact_to_chromium_and_deterministic() { let root = fixture_root(); + // Opt-in CI diagnostics. A fresh directory and create_new files preserve + // earlier failures; diagnostics never replace or relax an oracle. + let artifacts = std::env::var_os("N0_REFTEST_ARTIFACTS").map(PathBuf::from); + if let Some(path) = &artifacts { + fs::create_dir(path).expect("create new reftest artifact directory"); + } let mut divergences: Vec = Vec::new(); for fixture in suite().fixtures { let source = fs::read_to_string(root.join(&fixture.source)) @@ -413,6 +420,23 @@ fn every_primitive_is_pixel_exact_to_chromium_and_deterministic() { fixture.id ); let first_png = render_png_through_n0(&frame, fixture.width, fixture.height); + if differing_pixels != 0 + && fixture.tolerance.is_none() + && let Some(path) = &artifacts + { + let directory = path.join(&fixture.id); + fs::create_dir(&directory).expect("create new failed-case directory"); + for (name, bytes) in [ + ("actual.png", first_png.clone()), + ("reference.png", oracle_bytes.clone()), + ("source.txt", source.as_bytes().to_vec()), + ("frame.txt", format!("{frame:#?}").into_bytes()), + ] { + fs::File::create_new(directory.join(name)) + .and_then(|mut file| file.write_all(&bytes)) + .expect("preserve failed-case artifact without overwriting"); + } + } let second_png = render_png_through_n0(&frame, fixture.width, fixture.height); assert!( first_png == second_png, diff --git a/docs/wg/consolidation/svg-engine-of-record.md b/docs/wg/consolidation/svg-engine-of-record.md index 51d35d9a..ad555e53 100644 --- a/docs/wg/consolidation/svg-engine-of-record.md +++ b/docs/wg/consolidation/svg-engine-of-record.md @@ -6678,7 +6678,7 @@ oracle is replaced, and no tolerance or FLIP rule changes. The evidence estate is now 1,575 primitive cells and 368 named refusals; 16 sampled frames, 16 exact text cells and eight geometry witnesses are unchanged. Six new blur controls bring the filter estate to 459 cells. -The assertion harness gains six scene-specific rendering claims and two +The assertion harness gains seven scene-specific rendering claims and two expected refusals, separately labelled and run through the actual CLI. It uses the same immutable sources, exact decoded RGBA and sole Chromium module; it adds no second oracle or conformance measure. @@ -6713,7 +6713,7 @@ The renewed independent TICK/LAW audit and manual REPRO both pass with no remaining must-fix or should-fix items. REPRO repeated six matrices containing 95 sources through fresh Chromium and 380 actual CLI executions, verified stable identities and complete refusal-owner rollback, and renewed the full -fixture gate and STATUS. The six rendering assertions and two expected +fixture gate and STATUS. The six initial rendering assertions and two expected refusals pass, as does the original three-case pilot; both reports have empty integrity-error lists. The known unequal-scale no-opacity control remains outside the claimed repair. Full relevant Rust tests, post-audit producer and @@ -6721,3 +6721,23 @@ consumer tests including trace/reuse, formatting, strict Clippy, 40 assertion tool-contract tests, typechecks and link/OSS checks pass. No Workflow runner was exposed; the saved workflow was not invoked, and these independent roles are the explicit manual fallback. + +### Cross-platform landing boundary + +The verification above is local ARM evidence, not an all-platform verdict. +Hosted Linux capture exposed repeat-stable Chromium source-over rounding +differences in the three near-unit controls; the exact upstream NEON and x86 +formulas explain their complete images (**measured, not celled**). The +[reference-environment finding](https://github.com/gridaco/nothing/issues/140#issuecomment-5660399997) +separates portable engine correctness from reproduction of one declared +external capture environment. The original images and capture posture remain +unchanged; no tolerance or feature tick follows from that finding. + +A separate full Linux engine gate fails the authored-arc blur cell by 25 +pixels at maximum channel delta 3. Its retained failure signature matches +the local native-circle/arc distinction, but that alone does not establish a +cause. The existing arc cell now has a seventh actual-CLI rendering assertion +and failure artifacts preserve resolved geometry and pixels. This is an open +landing blocker, not a reference-policy exemption. Browser identity recording +and an observe-only ARM CI candidate prepare attestation; neither replaces the +Linux gates or constitutes an enforced canonical profile. diff --git a/fixtures/web-first/README.md b/fixtures/web-first/README.md index a1eed1f8..623e999c 100644 --- a/fixtures/web-first/README.md +++ b/fixtures/web-first/README.md @@ -493,7 +493,7 @@ a shadow corpus. The pre-landing verification ritual is the saved `assertions-test` checks the separate assertion-tool contracts; `assertions-gate ` runs its small actual-CLI pilot and fresh Chromium captures. `assertions-opacity-gate ` -runs six described blur/pattern/radial rendering claims and two named-refusal +runs seven described blur/pattern/radial rendering claims and two named-refusal claims from the ordinary-opacity rung. The [SVG assertion tool](../../packages/grida-reftest/svg-assertions/README.md) records each described claim, reference decision, both admissions and their diagnostics, independent exact image comparisons, and a discrete verdict. A diff --git a/packages/grida-reftest/svg-assertions/README.md b/packages/grida-reftest/svg-assertions/README.md index c0b29d00..71b9f41c 100644 --- a/packages/grida-reftest/svg-assertions/README.md +++ b/packages/grida-reftest/svg-assertions/README.md @@ -50,11 +50,13 @@ there is labelled **expected refusal**, never rendering support. ## Ordinary-opacity regression suite [opacity-source.json](./opacity-source.json) applies the same instrument to -six described rendering claims and two expected refusals from +seven described rendering claims and two expected refusals from [gridaco/nothing#136](https://github.com/gridaco/nothing/issues/136). The blur, repeating-pattern and radial-gradient scenes each pair group opacity `.999` with `.998`: a one-pixel discrepancy still fails the complete -scene, and each control must change Chromium's pixels. Sheared group and +scene, and each control must change Chromium's pixels. A separate authored-arc +blur case retains its own reference and uses the native circle as its control; +it guards the geometry distinction exposed by Linux CI. Sheared group and root-path cases check named refusals separately, not fallback-image accuracy. ```sh @@ -65,6 +67,17 @@ This uses a fresh output directory under the same setup and immutable-output rules as the pilot. CI runs both suites and retains both reports. No resvg reference, new tolerance, or claim of complete opacity support is introduced. +Captures retain `chromium-identity.json`: actual browser executable hash and +architecture, browser revision, host OS identity, and reported raster backend. +This passive record changes no launch flags or screenshot posture. It is not +yet an enforced canonical-environment profile. The ARM candidate CI job is +observe-only; it does not replace or satisfy the existing Linux gates. + +For the full Rust primitive gate, `N0_REFTEST_ARTIFACTS` may name a new directory +whose parent exists. Exact-cell divergences retain source, resolved frame, +actual PNG and reference PNG there. Existing output directories/files refuse +overwrites. CI uploads these diagnostics even when the test fails. + ## A case is not its filename ```text diff --git a/packages/grida-reftest/svg-assertions/capture-identity.test.ts b/packages/grida-reftest/svg-assertions/capture-identity.test.ts new file mode 100644 index 00000000..97db2c05 --- /dev/null +++ b/packages/grida-reftest/svg-assertions/capture-identity.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { executableArch } from "./capture-identity"; + +describe("actual browser executable architecture", () => { + it.each([[0x0100000c, "arm64"], [0x01000007, "x64"]] as const)("reads Mach-O CPU %i", (cpu, arch) => { + const header = Buffer.alloc(20); + header.writeUInt32LE(0xfeedfacf); header.writeUInt32LE(cpu, 4); + expect(executableArch(header)).toBe(arch); + }); + it.each([[183, "arm64"], [62, "x64"]] as const)("reads ELF CPU %i", (cpu, arch) => { + const header = Buffer.from([127, 69, 76, 70, 2, 1, ...Array(14).fill(0)]); + header.writeUInt16LE(cpu, 18); + expect(executableArch(header)).toBe(arch); + header[5] = 2; + expect(() => executableArch(header)).toThrow(); + }); + it("refuses unknown, truncated and non-executable identities", () => { + for (const bytes of [Buffer.alloc(0), Buffer.alloc(19), Buffer.alloc(20)]) + expect(() => executableArch(bytes)).toThrow(); + }); +}); diff --git a/packages/grida-reftest/svg-assertions/capture-identity.ts b/packages/grida-reftest/svg-assertions/capture-identity.ts new file mode 100644 index 00000000..0727f9d8 --- /dev/null +++ b/packages/grida-reftest/svg-assertions/capture-identity.ts @@ -0,0 +1,85 @@ +/** Passive provenance of the browser launched by the sole capture module. + * Owns no launcher, capture posture, image normalization, or reference choice. */ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { open, readlink } from "node:fs/promises"; +import { release, version } from "node:os"; +import { promisify } from "node:util"; +import type { Browser } from "@playwright/test"; + +/** Read the executable's ABI, not Node's architecture or the user-agent text. */ +export function executableArch(header: Buffer): "arm64" | "x64" { + if (header.length < 20) throw new Error("truncated executable header"); + if (header.readUInt32LE(0) === 0xfeedfacf) { + const cpu = header.readUInt32LE(4); + if (cpu === 0x0100000c) return "arm64"; + if (cpu === 0x01000007) return "x64"; + } + if (header.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && + header[4] === 2 && header[5] === 1) { + const cpu = header.readUInt16LE(18); + if (cpu === 183) return "arm64"; + if (cpu === 62) return "x64"; + } + throw new Error("unsupported browser executable ABI"); +} + +/** Hash one bounded regular executable without retaining it in memory. */ +async function executableIdentity(path: string) { + const file = await open(path, "r"); + try { + const before = await file.stat(); + if (!before.isFile() || before.size > 512 * 1024 * 1024) + throw new Error("browser executable outside file bound"); + const header = Buffer.alloc(20); + const { bytesRead } = await file.read(header, 0, header.length, 0); + const arch = executableArch(header.subarray(0, bytesRead)); + const hash = createHash("sha256"); + let count = 0; + for await (const chunk of createReadStream(path, { + fd: file.fd, autoClose: false, start: 0, + })) { + count += chunk.length; + if (count > before.size) throw new Error("browser executable grew"); + hash.update(chunk); + } + const after = await file.stat(); + if (count !== before.size || before.size !== after.size || + before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) + throw new Error("browser executable changed while reading"); + return { arch, sha256: hash.digest("hex") }; + } finally { await file.close(); } +} + +export async function captureIdentity(browser: Browser) { + const session = await browser.newBrowserCDPSession(); + try { + const browserVersion = await session.send("Browser.getVersion"); + const system = await session.send("SystemInfo.getInfo"); + const processes = await session.send("SystemInfo.getProcessInfo"); + const pid = processes.processInfo.find(p => p.type === "browser")?.id; + if (!pid || !Number.isSafeInteger(pid) || pid < 1) + throw new Error("browser process identity unavailable"); + let executable: string; + if (process.platform === "linux") executable = await readlink(`/proc/${pid}/exe`); + else if (process.platform === "darwin") { + executable = (await promisify(execFile)("ps", ["-p", String(pid), "-o", "comm="], { + timeout: 5000, maxBuffer: 16384, + })).stdout.trim(); + } else throw new Error("browser identity requires Linux or macOS"); + const binary = await executableIdentity(executable); + const aux = system.gpu.auxAttributes ?? {}; + return { + schema_version: 1, + host: { platform: process.platform, arch: process.arch, release: release(), version: version() }, + browser: { product: browserVersion.product, revision: browserVersion.revision, ...binary }, + raster: { + feature_status: system.gpu.featureStatus, + gl_implementation: aux.glImplementationParts ?? null, + gl_renderer: aux.glRenderer ?? null, + gl_version: aux.glVersion ?? null, + }, + }; + } finally { await session.detach(); } +} diff --git a/packages/grida-reftest/svg-assertions/capture-worker.ts b/packages/grida-reftest/svg-assertions/capture-worker.ts index 3664c7b2..559713e8 100644 --- a/packages/grida-reftest/svg-assertions/capture-worker.ts +++ b/packages/grida-reftest/svg-assertions/capture-worker.ts @@ -1,6 +1,7 @@ /** Bounded by the parent process. No alternate Chromium capture posture. */ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { captureIdentity } from "./capture-identity"; import { captureFirstSvg, deterministicContext, @@ -16,6 +17,8 @@ async function main(): Promise { throw new Error( `Chromium version drift: ${browser.version()} != ${version}` ); + await writeFile(join(out, "chromium-identity.json"), + JSON.stringify(await captureIdentity(browser), null, 2), { flag: "wx" }); const context = await deterministicContext(browser); try { const page = await context.newPage(); diff --git a/packages/grida-reftest/svg-assertions/opacity-source.json b/packages/grida-reftest/svg-assertions/opacity-source.json index df071236..ee173f0f 100644 --- a/packages/grida-reftest/svg-assertions/opacity-source.json +++ b/packages/grida-reftest/svg-assertions/opacity-source.json @@ -6,6 +6,35 @@ "browser_version": "149.0.7827.55" }, "cases": [ + { + "id": "svg-opacity-source-blur-arc", + "source": { + "path": "../../../fixtures/web-first/svg-opacity-source-blur-arc.svg", + "sha256": "0bf9a3406281d362f05026282221fb9d9416029b9ed923d0ec2ff6958a685650" + }, + "description": "The authored arc path feeding Gaussian blur at group opacity .999 matches its own Chromium reference, not the native-circle control. This preserves the geometry distinction through the actual CLI on every tested platform.", + "required": true, + "width": 64, + "height": 64, + "review": { + "status": "reviewed", + "reason": "Existing immutable registered cell; self-contained and static. The arc and native-circle controls differ by 25 Chromium pixels at maximum channel delta 3. A known Linux engine failure must remain visible until repaired.", + "blockers": [] + }, + "assertion": { + "kind": "render-exact", + "reference": { + "path": "../../../fixtures/web-first/chromium/svg-opacity-source-blur-arc.png", + "sha256": "5d781e8dc2f645b2f51b6e2a1e118d3d6d7a8114ea20a2ab81beb617790ac71d" + }, + "decision": "The arc retains its own external reference; no geometry substitution or platform-specific expectation.", + "control": { + "case": "svg-opacity-source-blur-high", + "why": "Native circle and authored arcs are distinct Chromium raster branches: 25 pixels differ at maximum channel delta 3. Each must match its own complete image." + } + }, + "stored": null + }, { "id": "svg-opacity-source-blur-high", "source": { diff --git a/packages/grida-reftest/svg-assertions/runner.ts b/packages/grida-reftest/svg-assertions/runner.ts index d60b4906..01cd9cbd 100644 --- a/packages/grida-reftest/svg-assertions/runner.ts +++ b/packages/grida-reftest/svg-assertions/runner.ts @@ -272,6 +272,7 @@ export async function run(options: Options): Promise { "runner.ts", "report.ts", "capture-worker.ts", + "capture-identity.ts", "cli.ts", ].map((name) => join(toolDir, name)), ]) { From 2b49e31c8e10afca943e7c04e1f6dc38e7b88b2c Mon Sep 17 00:00:00 2001 From: Universe Date: Fri, 18 Sep 2026 15:38:49 +0900 Subject: [PATCH 3/3] websem: pin reference environment and preserve arc axis precision --- .github/workflows/consolidation-gates.yml | 24 ++-- crates/n0_cli/README.md | 6 +- crates/websem/src/svg_path.rs | 50 +++++++- docs/wg/consolidation/svg-engine-of-record.md | 32 +++-- fixtures/web-first/README.md | 6 +- .../grida-reftest/svg-assertions/README.md | 44 +++++-- .../svg-assertions/capture-identity.test.ts | 26 +++- .../svg-assertions/capture-identity.ts | 54 ++++++--- .../svg-assertions/capture-worker.ts | 7 +- packages/grida-reftest/svg-assertions/cli.ts | 23 +++- .../svg-assertions/model.test.ts | 53 ++++++++- .../grida-reftest/svg-assertions/model.ts | 55 ++++++--- .../svg-assertions/opacity-source.json | 40 ++++++- .../grida-reftest/svg-assertions/pilot.json | 40 ++++++- .../reference-environment.test.ts | 65 ++++++++++ .../svg-assertions/reference-environment.ts | 96 +++++++++++++++ .../grida-reftest/svg-assertions/report.ts | 4 +- .../grida-reftest/svg-assertions/runner.ts | 112 ++++++++++++------ 18 files changed, 626 insertions(+), 111 deletions(-) create mode 100644 packages/grida-reftest/svg-assertions/reference-environment.test.ts create mode 100644 packages/grida-reftest/svg-assertions/reference-environment.ts diff --git a/.github/workflows/consolidation-gates.yml b/.github/workflows/consolidation-gates.yml index cbea8bad..0f77e5b6 100644 --- a/.github/workflows/consolidation-gates.yml +++ b/.github/workflows/consolidation-gates.yml @@ -197,10 +197,10 @@ jobs: pnpm -C packages/grida-reftest exec vitest run svg-assertions --passWithNoTests=false pnpm -C packages/grida-reftest exec tsx svg-assertions/cli.ts \ --manifest "$GITHUB_WORKSPACE/packages/grida-reftest/svg-assertions/pilot.json" \ - --out "$RUNNER_TEMP/$SVG_ASSERTION_DIR" + --out "$RUNNER_TEMP/$SVG_ASSERTION_DIR" --obligation engine pnpm -C packages/grida-reftest exec tsx svg-assertions/cli.ts \ --manifest "$GITHUB_WORKSPACE/packages/grida-reftest/svg-assertions/opacity-source.json" \ - --out "$RUNNER_TEMP/$SVG_ASSERTION_DIR/opacity-source" + --out "$RUNNER_TEMP/$SVG_ASSERTION_DIR/opacity-source" --obligation engine - name: Upload SVG assertion observations if: always() @@ -211,10 +211,11 @@ jobs: if-no-files-found: warn retention-days: 7 - svg_arm_capture_candidate: - # Attestation candidate, not a replacement for the Linux engine gates. - # Until its exact identity is reviewed/pinned, this job certifies no gate. - name: SVG ARM capture candidate + svg_reference: + # macos-26 provisions a candidate; the manifest's exact runtime identity + # decides whether it is the reference environment. Drift fails closed. + # Linux still must match these SAME immutable references independently. + name: SVG canonical reference needs: scope if: needs.scope.outputs.engine == 'true' runs-on: macos-26 @@ -223,6 +224,7 @@ jobs: - uses: actions/checkout@v4 with: lfs: true + ref: ${{ needs.scope.outputs.head }} - uses: dtolnay/rust-toolchain@stable with: toolchain: 1.92.0 @@ -240,11 +242,14 @@ jobs: brew install ninja pnpm install --frozen-lockfile pnpm -C packages/grida-reftest exec playwright install chromium - - name: Observe immutable opacity references and retain browser identity + - name: Require canonical reproduction and ARM engine pixels run: | + pnpm -C packages/grida-reftest exec tsx svg-assertions/cli.ts \ + --manifest "$GITHUB_WORKSPACE/packages/grida-reftest/svg-assertions/pilot.json" \ + --out "$RUNNER_TEMP/svg-arm-capture" pnpm -C packages/grida-reftest exec tsx svg-assertions/cli.ts \ --manifest "$GITHUB_WORKSPACE/packages/grida-reftest/svg-assertions/opacity-source.json" \ - --out "$RUNNER_TEMP/svg-arm-capture" --observe + --out "$RUNNER_TEMP/svg-arm-capture/opacity-source" - uses: actions/upload-artifact@v4 if: always() with: @@ -482,6 +487,7 @@ jobs: - scope - lock_additions_only - seam_architecture + - svg_reference - legacy_pixel_sweep - n0_gate - scoreboard_shape @@ -493,6 +499,7 @@ jobs: ENGINE_SCOPE: ${{ needs.scope.outputs.engine }} LOCK_RESULT: ${{ needs.lock_additions_only.result }} SEAM_RESULT: ${{ needs.seam_architecture.result }} + REFERENCE_RESULT: ${{ needs.svg_reference.result }} PIXEL_RESULT: ${{ needs.legacy_pixel_sweep.result }} N0_RESULT: ${{ needs.n0_gate.result }} SCOREBOARD_RESULT: ${{ needs.scoreboard_shape.result }} @@ -502,6 +509,7 @@ jobs: fi if [[ "$ENGINE_SCOPE" == true ]] && \ [[ "$SEAM_RESULT" != success || \ + "$REFERENCE_RESULT" != success || \ "$PIXEL_RESULT" != success || \ "$N0_RESULT" != success || \ "$SCOREBOARD_RESULT" != success ]]; then diff --git a/crates/n0_cli/README.md b/crates/n0_cli/README.md index 96ca9066..8a57e3ab 100644 --- a/crates/n0_cli/README.md +++ b/crates/n0_cli/README.md @@ -84,7 +84,11 @@ cargo run -p n0_cli --bin n0 -- \ before a syntax error survives; an empty prefix paints nothing; ordinary non-finite derived verbs invalidate the path while an extreme arc may append no segment and preserve prior ink; and elliptical arcs resolve through the - pinned Skia conic construction. Six new `d` cells plus one companion cell + pinned Skia conic construction. Its negative-horizontal-axis angle boundary + is explicit across engine hosts, preserving the declared ARM Chromium + reference's conic weights rather than a host math library's last-bit choice; + the filtered authored-arc cell guards the resulting pixels. This is not a + blanket guarantee about platform transcendental math. Six new `d` cells plus one companion cell for the shared `points` scanner carry those boundaries. The CSS `d` property remains a named refusal because the pinned cascade has no corresponding longhand. Also admitted are ``, `` diff --git a/crates/websem/src/svg_path.rs b/crates/websem/src/svg_path.rs index 76ee8b6d..fa9b220d 100644 --- a/crates/websem/src/svg_path.rs +++ b/crates/websem/src/svg_path.rs @@ -1255,8 +1255,8 @@ fn resolve_arc_like_skia( unit_end.0 -= center.0; unit_end.1 -= center.1; - let theta1 = unit_start.1.atan2(unit_start.0); - let theta2 = unit_end.1.atan2(unit_end.0); + let theta1 = raster_arc_angle(unit_start.1, unit_start.0); + let theta2 = raster_arc_angle(unit_end.1, unit_end.0); let mut theta_arc = theta2 - theta1; let tau = std::f32::consts::PI * 2.0; if theta_arc < 0.0 && sweep { @@ -1317,6 +1317,21 @@ fn resolve_arc_like_skia( ArcResolution::Conics(conics) } +/// Preserve the declared ARM Chromium arc boundary on every engine host. +/// Its `atan2f(+-0, negative finite)` is +-0x40490fda, one ULP below Rust's +/// PI constant (and Linux libm's answer). That difference survives arc-span +/// subtraction into conic weights; rounding weights or substituting an oval +/// changes filtered pixels. `f32::atan2` explicitly has unspecified precision. +/// This fixes the measured axis boundary, not all platform transcendental math. +/// The existing `svg-opacity-source-blur-arc` oracle guards the complete image. +fn raster_arc_angle(y: f32, x: f32) -> f32 { + if y == 0.0 && x.is_finite() && x < 0.0 { + f32::from_bits(0x4049_0fda).copysign(y) + } else { + y.atan2(x) + } +} + fn skia_sin_cos(degrees: f32) -> (f32, f32) { skia_sin_cos_radians(degrees * (std::f32::consts::PI / 180.0)) } @@ -1350,6 +1365,37 @@ fn finite_point(point: (f32, f32)) -> bool { mod marker_tests { use super::*; + #[test] + fn raster_arc_negative_axis_is_not_host_libm_pi() { + for x in [-f32::MIN_POSITIVE, -1.0, -20.0, -f32::MAX] { + assert_eq!(raster_arc_angle(0.0, x).to_bits(), 0x4049_0fda); + assert_eq!(raster_arc_angle(-0.0, x).to_bits(), 0xc049_0fda); + } + assert_eq!(raster_arc_angle(0.0, 1.0).to_bits(), 0); + assert_eq!(raster_arc_angle(-0.0, 1.0).to_bits(), 0x8000_0000); + assert_eq!(raster_arc_angle(1.0, 0.0), std::f32::consts::FRAC_PI_2); + assert_eq!(raster_arc_angle(-1.0, 0.0), -std::f32::consts::FRAC_PI_2); + assert!(raster_arc_angle(f32::NAN, -1.0).is_nan()); + } + + #[test] + fn raster_semicircle_retains_reference_conic_weights_on_every_host() { + for (start, end, expected) in [ + ((10.0, 30.0), (50.0, 30.0), 0x3f35_04f2), + ((50.0, 30.0), (10.0, 30.0), 0x3f35_04f4), + ] { + let ArcResolution::Conics(conics) = + resolve_arc_like_skia(start, (20.0, 20.0), 0.0, true, true, end) + else { + panic!("expected resolved semicircle"); + }; + assert_eq!(conics.len(), 2); + for (_, _, weight) in conics { + assert_eq!(weight.to_bits(), expected); + } + } + } + fn assert_position(actual: MarkerPosition, kind: MarkerType, origin: (f32, f32), angle: f32) { assert_eq!(actual.kind, kind); assert_eq!(actual.origin, origin); diff --git a/docs/wg/consolidation/svg-engine-of-record.md b/docs/wg/consolidation/svg-engine-of-record.md index ad555e53..67853198 100644 --- a/docs/wg/consolidation/svg-engine-of-record.md +++ b/docs/wg/consolidation/svg-engine-of-record.md @@ -6733,11 +6733,27 @@ separates portable engine correctness from reproduction of one declared external capture environment. The original images and capture posture remain unchanged; no tolerance or feature tick follows from that finding. -A separate full Linux engine gate fails the authored-arc blur cell by 25 -pixels at maximum channel delta 3. Its retained failure signature matches -the local native-circle/arc distinction, but that alone does not establish a -cause. The existing arc cell now has a seventh actual-CLI rendering assertion -and failure artifacts preserve resolved geometry and pixels. This is an open -landing blocker, not a reference-policy exemption. Browser identity recording -and an observe-only ARM CI candidate prepare attestation; neither replaces the -Linux gates or constitutes an enforced canonical profile. +A separate full Linux engine gate exposed 25 differing pixels at maximum +channel delta 3 in the authored-arc blur cell. Retained resolved geometry +then isolated different conic weights with identical control points. The +existing arc cell now has a seventh actual-CLI rendering assertion and failure +artifacts preserve resolved geometry and pixels. Its engine obligation cannot +be waived by the reference policy. Browser identity recording +initially prepared attestation without replacing the Linux gates. The declared +reference contract now separates engine portability from canonical browser +reproduction: all engine hosts compare against the same immutable pixels; +fresh browser captures certify reproduction only with the complete declared +runtime identity. Both obligations are independently required by CI. A runner +label is provisioning, not a reference pin. The +[assertion harness contract](../../../packages/grida-reftest/svg-assertions/README.md#ordinary-opacity-regression-suite) +owns the binding and attestation evidence. Original bake provenance remains +untouched; this is not a new oracle or a tolerance. + +The arc failure has a separate producer cause: the reference environment's +single-precision angle function returns one bit below the usual constant on +the negative horizontal axis. The resulting unequal half-circle conic weights +are visible after blur. The resolver carries that measured axis boundary +explicitly on every engine host; it does not round curve weights, replace the +authored arc with an oval, or change the painter. The existing exact arc cell +and numeric boundary tests guard this distinction. This is not a claim that +all transcendental operations are platform-independent. diff --git a/fixtures/web-first/README.md b/fixtures/web-first/README.md index 623e999c..947dc435 100644 --- a/fixtures/web-first/README.md +++ b/fixtures/web-first/README.md @@ -498,5 +498,9 @@ claims from the ordinary-opacity rung. The [SVG assertion tool](../../packages/g records each described claim, reference decision, both admissions and their diagnostics, independent exact image comparisons, and a discrete verdict. A verified refusal is not rendering support; unresolved or missing required -cases cannot silently pass. This supplements the existing corpus gates and +cases cannot silently pass. The default obligation also requires the manifest's +exact canonical Chromium environment; other hosts can explicitly run the CLI's +`--obligation engine` against the same immutable references. Reports retain +engine-only and combined verdicts separately, and CI requires both Linux engine +portability and canonical ARM reproduction. This supplements the existing corpus gates and uses the same capture module. It does not create a score or a second checklist. diff --git a/packages/grida-reftest/svg-assertions/README.md b/packages/grida-reftest/svg-assertions/README.md index 71b9f41c..91211153 100644 --- a/packages/grida-reftest/svg-assertions/README.md +++ b/packages/grida-reftest/svg-assertions/README.md @@ -68,10 +68,39 @@ rules as the pilot. CI runs both suites and retains both reports. No resvg reference, new tolerance, or claim of complete opacity support is introduced. Captures retain `chromium-identity.json`: actual browser executable hash and -architecture, browser revision, host OS identity, and reported raster backend. -This passive record changes no launch flags or screenshot posture. It is not -yet an enforced canonical-environment profile. The ARM candidate CI job is -observe-only; it does not replace or satisfy the existing Linux gates. +architecture, browser revision, host kernel identity, and reported raster backend. +This record changes no launch flags or screenshot posture. Manifest version 2 +declares the complete required identity in `capture.environment`. Missing, +unknown or changed fields fail reference reproduction closed, even when pixels +match. The Node version, dependency lock, capture module and tool hashes are +also recorded; the unchanged capture module still owns all launch settings. + +There are two obligations, never two references: + +- **Engine:** both actual CLI admissions and repeats match the same immutable + baked pixels on every tested platform, with an effective baked control and + exact refusal diagnostics. `--obligation engine` selects only this obligation. +- **Engine and reference (default):** additionally, fresh repeated Chromium + captures in the declared environment reproduce those pixels and the active + control. A different Chromium environment is an observation, not an alternate + oracle or a reason to change n0 pixels. + +Reports always retain both verdicts. `engine_ready` describes the first; +`gate_ready` always describes the combined obligation, regardless of the +selected command-line obligation. Engine-only output is explicitly labelled. +The required Linux seam job checks engine portability. The separately required +ARM reference job checks both obligations; aggregate CI refuses a missing, +skipped or failing applicable reference job. `macos-26` is a provisioner, not +the pin: runtime fingerprint drift fails until separately reviewed and attested. + +The declared ARM software target preserves the existing independently baked +references. Attestation in [CI run 35314071984](https://github.com/gridaco/nothing/actions/runs/35314071984) +reproduced all seven opacity scenes and both refusal assertions, with the same +identity across every capture (measured, not celled). This is new reproduction +evidence, not retroactive provenance for earlier bakes. Kernel updates require +a new explicit attestation; matching pictures alone never select the profile. +Other-host local runs should use `--obligation engine` through the CLI, or expect +the default combined obligation to report an environment mismatch. For the full Rust primitive gate, `N0_REFTEST_ARTIFACTS` may name a new directory whose parent exists. Exact-cell divergences retain source, resolved frame, @@ -93,7 +122,7 @@ Input identity + declared environment + described claim ``` [pilot.json](./pilot.json) is a complete manifest example. All fields are -mandatory, including explicit `null` where an optional reference or assertion +mandatory, including the reference environment and explicit `null` where an optional reference or assertion does not exist. Unknown fields, duplicate IDs, missing controls, invalid hashes, and empty suites are errors. Paths are relative to the manifest, not the shell. Source bytes and any baked/stored PNG are SHA-256 pinned. The manifest and tool @@ -229,8 +258,9 @@ The source corpus and authoritative evidence and work queue; this pilot is not another support list. Consolidation's seam job runs the synthetic contract tests (zero discovered -tests is a failure) and real CLI pilot -on a clean checkout, then retains its report for review. Changes to this tool +tests is a failure) and both real CLI suites' engine obligations +on a clean checkout; the canonical-reference job runs both combined obligations. +Both retain reports for review. Changes to this tool or its dependency pins activate that gate. Existing Rust pixel/refusal tests continue to run independently. Legacy runner retirement, WPT adapters, wider resource/font profiles, a general consensus rule, and FLIP are not implemented. diff --git a/packages/grida-reftest/svg-assertions/capture-identity.test.ts b/packages/grida-reftest/svg-assertions/capture-identity.test.ts index 97db2c05..177ec263 100644 --- a/packages/grida-reftest/svg-assertions/capture-identity.test.ts +++ b/packages/grida-reftest/svg-assertions/capture-identity.test.ts @@ -2,20 +2,34 @@ import { describe, expect, it } from "vitest"; import { executableArch } from "./capture-identity"; describe("actual browser executable architecture", () => { - it.each([[0x0100000c, "arm64"], [0x01000007, "x64"]] as const)("reads Mach-O CPU %i", (cpu, arch) => { + it.each([ + [0x0100000c, "arm64"], + [0x01000007, "x64"], + ] as const)("reads Mach-O CPU %i", (cpu, arch) => { const header = Buffer.alloc(20); - header.writeUInt32LE(0xfeedfacf); header.writeUInt32LE(cpu, 4); + header.writeUInt32LE(0xfeedfacf); + header.writeUInt32LE(cpu, 4); expect(executableArch(header)).toBe(arch); }); - it.each([[183, "arm64"], [62, "x64"]] as const)("reads ELF CPU %i", (cpu, arch) => { + it.each([ + [183, "arm64"], + [62, "x64"], + ] as const)("reads ELF CPU %i", (cpu, arch) => { const header = Buffer.from([127, 69, 76, 70, 2, 1, ...Array(14).fill(0)]); header.writeUInt16LE(cpu, 18); expect(executableArch(header)).toBe(arch); header[5] = 2; - expect(() => executableArch(header)).toThrow(); + expect(() => executableArch(header)).toThrow( + "unsupported browser executable ABI" + ); }); it("refuses unknown, truncated and non-executable identities", () => { - for (const bytes of [Buffer.alloc(0), Buffer.alloc(19), Buffer.alloc(20)]) - expect(() => executableArch(bytes)).toThrow(); + for (const bytes of [Buffer.alloc(0), Buffer.alloc(19)]) + expect(() => executableArch(bytes)).toThrow( + "truncated executable header" + ); + expect(() => executableArch(Buffer.alloc(20))).toThrow( + "unsupported browser executable ABI" + ); }); }); diff --git a/packages/grida-reftest/svg-assertions/capture-identity.ts b/packages/grida-reftest/svg-assertions/capture-identity.ts index 0727f9d8..19900758 100644 --- a/packages/grida-reftest/svg-assertions/capture-identity.ts +++ b/packages/grida-reftest/svg-assertions/capture-identity.ts @@ -16,8 +16,11 @@ export function executableArch(header: Buffer): "arm64" | "x64" { if (cpu === 0x0100000c) return "arm64"; if (cpu === 0x01000007) return "x64"; } - if (header.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && - header[4] === 2 && header[5] === 1) { + if ( + header.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && + header[4] === 2 && + header[5] === 1 + ) { const cpu = header.readUInt16LE(18); if (cpu === 183) return "arm64"; if (cpu === 62) return "x64"; @@ -38,18 +41,26 @@ async function executableIdentity(path: string) { const hash = createHash("sha256"); let count = 0; for await (const chunk of createReadStream(path, { - fd: file.fd, autoClose: false, start: 0, + fd: file.fd, + autoClose: false, + start: 0, })) { count += chunk.length; if (count > before.size) throw new Error("browser executable grew"); hash.update(chunk); } const after = await file.stat(); - if (count !== before.size || before.size !== after.size || - before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) + if ( + count !== before.size || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs + ) throw new Error("browser executable changed while reading"); return { arch, sha256: hash.digest("hex") }; - } finally { await file.close(); } + } finally { + await file.close(); + } } export async function captureIdentity(browser: Browser) { @@ -58,22 +69,35 @@ export async function captureIdentity(browser: Browser) { const browserVersion = await session.send("Browser.getVersion"); const system = await session.send("SystemInfo.getInfo"); const processes = await session.send("SystemInfo.getProcessInfo"); - const pid = processes.processInfo.find(p => p.type === "browser")?.id; + const pid = processes.processInfo.find((p) => p.type === "browser")?.id; if (!pid || !Number.isSafeInteger(pid) || pid < 1) throw new Error("browser process identity unavailable"); let executable: string; - if (process.platform === "linux") executable = await readlink(`/proc/${pid}/exe`); + if (process.platform === "linux") + executable = await readlink(`/proc/${pid}/exe`); else if (process.platform === "darwin") { - executable = (await promisify(execFile)("ps", ["-p", String(pid), "-o", "comm="], { - timeout: 5000, maxBuffer: 16384, - })).stdout.trim(); + executable = ( + await promisify(execFile)("ps", ["-p", String(pid), "-o", "comm="], { + timeout: 5000, + maxBuffer: 16384, + }) + ).stdout.trim(); } else throw new Error("browser identity requires Linux or macOS"); const binary = await executableIdentity(executable); const aux = system.gpu.auxAttributes ?? {}; return { schema_version: 1, - host: { platform: process.platform, arch: process.arch, release: release(), version: version() }, - browser: { product: browserVersion.product, revision: browserVersion.revision, ...binary }, + host: { + platform: process.platform, + arch: process.arch, + release: release(), + version: version(), + }, + browser: { + product: browserVersion.product, + revision: browserVersion.revision, + ...binary, + }, raster: { feature_status: system.gpu.featureStatus, gl_implementation: aux.glImplementationParts ?? null, @@ -81,5 +105,7 @@ export async function captureIdentity(browser: Browser) { gl_version: aux.glVersion ?? null, }, }; - } finally { await session.detach(); } + } finally { + await session.detach(); + } } diff --git a/packages/grida-reftest/svg-assertions/capture-worker.ts b/packages/grida-reftest/svg-assertions/capture-worker.ts index 559713e8..10aafa7d 100644 --- a/packages/grida-reftest/svg-assertions/capture-worker.ts +++ b/packages/grida-reftest/svg-assertions/capture-worker.ts @@ -17,8 +17,11 @@ async function main(): Promise { throw new Error( `Chromium version drift: ${browser.version()} != ${version}` ); - await writeFile(join(out, "chromium-identity.json"), - JSON.stringify(await captureIdentity(browser), null, 2), { flag: "wx" }); + await writeFile( + join(out, "chromium-identity.json"), + JSON.stringify(await captureIdentity(browser), null, 2), + { flag: "wx" } + ); const context = await deterministicContext(browser); try { const page = await context.newPage(); diff --git a/packages/grida-reftest/svg-assertions/cli.ts b/packages/grida-reftest/svg-assertions/cli.ts index 4026c24e..6b2149c1 100644 --- a/packages/grida-reftest/svg-assertions/cli.ts +++ b/packages/grida-reftest/svg-assertions/cli.ts @@ -15,6 +15,7 @@ async function main(): Promise { ![ "--manifest", "--out", + "--obligation", "--resvg", "--resvg-sha256", "--resvg-version", @@ -27,12 +28,16 @@ async function main(): Promise { } if (!values.get("--manifest") || !values.get("--out")) throw new Error( - "usage: cli.ts --manifest FILE --out NEW_DIRECTORY [--observe] [--resvg FILE --resvg-sha256 HASH --resvg-version TEXT]" + "usage: cli.ts --manifest FILE --out NEW_DIRECTORY [--obligation engine|engine-and-reference] [--observe] [--resvg FILE --resvg-sha256 HASH --resvg-version TEXT]" ); const options: Options = { manifest: resolve(values.get("--manifest")!), out: resolve(values.get("--out")!), }; + const obligation = values.get("--obligation") ?? "engine-and-reference"; + if (obligation !== "engine" && obligation !== "engine-and-reference") + throw new Error("unknown assertion obligation"); + options.obligation = obligation; if ( ["--resvg", "--resvg-sha256", "--resvg-version"].some((key) => values.has(key) @@ -48,9 +53,16 @@ async function main(): Promise { options.resvg = { executable, sha256, version }; } const report = await run(options); - for (const r of report.cases) + console.log(`Obligation: ${report.obligation}`); + for (const r of report.cases) { + const verdict = obligation === "engine" ? r.engine_verdict : r.verdict; console.log( - `${r.verdict.status} ${r.case.id} [${r.verdict.kind}]: ${r.verdict.reasons.join("; ")}` + `${verdict.status} ${r.case.id} [${verdict.kind}; ${obligation}]: ${verdict.reasons.join("; ")}` + ); + } + if (obligation === "engine") + console.log( + "ENGINE ONLY: canonical reference reproduction is a separate required job; full reference gate_ready remains recorded independently." ); console.log(`Observations: ${options.out}/index.html`); if (observe) { @@ -58,7 +70,10 @@ async function main(): Promise { "OBSERVE ONLY: this invocation does not certify a required gate." ); if (report.integrity.length) process.exitCode = 1; - } else if (!report.gate_ready) process.exitCode = 1; + } else if ( + !(obligation === "engine" ? report.engine_ready : report.gate_ready) + ) + process.exitCode = 1; } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); diff --git a/packages/grida-reftest/svg-assertions/model.test.ts b/packages/grida-reftest/svg-assertions/model.test.ts index 830b362f..99eb802e 100644 --- a/packages/grida-reftest/svg-assertions/model.test.ts +++ b/packages/grida-reftest/svg-assertions/model.test.ts @@ -45,9 +45,33 @@ function rendering(id = "subject", control = "control"): Case { } function suite(): Suite { return { - schema_version: 1, + schema_version: 2, profile: "static-self-contained-svg-v1", - capture: { sha256: hash, browser_version: "test-version" }, + capture: { + sha256: hash, + browser_version: "test-version", + environment: { + schema_version: 1, + host: { + platform: "test", + arch: "test", + release: "test", + version: "test", + }, + browser: { + product: "test", + revision: "test", + arch: "test", + sha256: hash, + }, + raster: { + feature_status: { rasterization: "disabled_software" }, + gl_implementation: "test", + gl_renderer: "test", + gl_version: "test", + }, + }, + }, cases: [rendering(), rendering("control", "subject")], }; } @@ -118,12 +142,14 @@ function evidence(): Evidence { strict: observation(), best: observation(), chromium: observation(), + reference_problem: null, pairs: { "strict-baked": compare(original, original), "best-baked": compare(original, original), "chromium-baked": compare(original, original), "strict-best": compare(original, original), "chromium-control": compare(original, changed), + "baked-control": compare(original, changed), }, }; } @@ -326,6 +352,29 @@ describe("exact pixels, not image grades", () => { }); }); describe("assertion verdicts", () => { + it("keeps portable engine success separate from canonical reproduction", () => { + const e = evidence(); + e.reference_problem = "reference-environment-mismatch: browser"; + e.pairs["chromium-baked"] = compare(original, changed); + expect(evaluate(rendering(), e, "engine").status).toBe("PASS"); + expect(evaluate(rendering(), e).status).toBe("UNRESOLVED"); + e.reference_problem = null; + expect(evaluate(rendering(), e).status).toBe("FAIL"); + e.pairs["strict-baked"] = compare(original, changed); + expect(evaluate(rendering(), e, "engine").status).toBe("FAIL"); + expect(evaluate(rendering(), e).status).toBe("FAIL"); + }); + it("requires an effective immutable control even for engine-only verification", () => { + const e = evidence(); + delete e.pairs["baked-control"]; + expect(evaluate(rendering(), e, "engine").status).toBe("UNRESOLVED"); + expect(evaluate(rendering(), e).status).toBe("UNRESOLVED"); + }); + it("requires explicit reference identity even if every pixel matches", () => { + const e = evidence(); + e.reference_problem = undefined as unknown as string; + expect(evaluate(rendering(), e).status).toBe("UNRESOLVED"); + }); it("accepts a fully evidenced exact assertion", () => expect(evaluate(rendering(), evidence()).status).toBe("PASS")); it("does not let a matching second opinion rescue a declared-reference mismatch", () => { diff --git a/packages/grida-reftest/svg-assertions/model.ts b/packages/grida-reftest/svg-assertions/model.ts index bb84c407..ce13dc84 100644 --- a/packages/grida-reftest/svg-assertions/model.ts +++ b/packages/grida-reftest/svg-assertions/model.ts @@ -3,6 +3,10 @@ import { createHash } from "node:crypto"; import { PNG } from "pngjs"; import { crc32, inflateSync } from "node:zlib"; +import { + parseEnvironment, + type ReferenceEnvironment, +} from "./reference-environment"; export type FileIdentity = { path: string; sha256: string }; export type Assertion = @@ -34,9 +38,13 @@ export interface Case { stored: FileIdentity | null; } export interface Suite { - schema_version: 1; + schema_version: 2; profile: "static-self-contained-svg-v1"; - capture: { sha256: string; browser_version: string }; + capture: { + sha256: string; + browser_version: string; + environment: ReferenceEnvironment; + }; cases: Case[]; } export const sha256 = (bytes: Uint8Array | string): string => @@ -113,9 +121,9 @@ function assertion(value: unknown): Assertion | null { } export function parseSuite(value: unknown): Suite { const s = object(value, ["schema_version", "profile", "capture", "cases"]); - if (s.schema_version !== 1 || s.profile !== "static-self-contained-svg-v1") + if (s.schema_version !== 2 || s.profile !== "static-self-contained-svg-v1") throw new Error("unsupported manifest profile/version"); - const cap = object(s.capture, ["sha256", "browser_version"]); + const cap = object(s.capture, ["sha256", "browser_version", "environment"]); if (!Array.isArray(s.cases) || !s.cases.length || s.cases.length > 128) throw new Error("declare 1..128 cases"); const cases = s.cases.map((value) => { @@ -171,11 +179,12 @@ export function parseSuite(value: unknown): Suite { throw new Error(`${c.id}: missing or self-referential control`); } return { - schema_version: 1, + schema_version: 2, profile: "static-self-contained-svg-v1", capture: { sha256: hash(cap.sha256), browser_version: text(cap.browser_version), + environment: parseEnvironment(cap.environment), }, cases, }; @@ -399,6 +408,7 @@ export interface Evidence { strict: Observation; best: Observation; chromium: Observation; + reference_problem: string | null; pairs: Record; } function sameImage(a: ImageRecord | null, b: ImageRecord | null): boolean { @@ -443,7 +453,12 @@ function rendered(o: Observation, c: Case): boolean { ) ); } -export function evaluate(c: Case, e: Evidence): Verdict { +export type Obligation = "engine" | "engine-and-reference"; +export function evaluate( + c: Case, + e: Evidence, + obligation: Obligation = "engine-and-reference" +): Verdict { const kind = c.assertion?.kind ?? "observation"; if (!c.assertion) return { @@ -453,6 +468,7 @@ export function evaluate(c: Case, e: Evidence): Verdict { }; if ( e.problems.length || + (obligation === "engine-and-reference" && e.reference_problem !== null) || c.review.status !== "reviewed" || c.review.blockers.length ) @@ -461,11 +477,21 @@ export function evaluate(c: Case, e: Evidence): Verdict { kind, reasons: [ ...e.problems, + ...(obligation === "engine-and-reference" && + e.reference_problem !== null + ? [e.reference_problem ?? "reference environment not attested"] + : []), ...c.review.blockers, ...(c.review.status !== "reviewed" ? ["claim not reviewed"] : []), ], }; const reasons: string[] = []; + if (obligation === "engine-and-reference" && !rendered(e.chromium, c)) + return { + status: "UNRESOLVED", + kind, + reasons: ["Chromium reference observation unavailable or unstable"], + }; if (c.assertion.kind === "refusal") { for (const admission of ["strict", "best"] as const) { const o = e[admission], @@ -490,24 +516,19 @@ export function evaluate(c: Case, e: Evidence): Verdict { } else { if (!rendered(e.strict, c) || !rendered(e.best, c)) reasons.push("n0 did not render repeatably without degradation"); - if (!rendered(e.chromium, c)) - return { - status: "UNRESOLVED", - kind, - reasons: [ - ...reasons, - "Chromium reference observation unavailable or unstable", - ], - }; for (const pair of [ "strict-baked", "best-baked", - "chromium-baked", "strict-best", + ...(obligation === "engine-and-reference" ? ["chromium-baked"] : []), ]) if (e.pairs[pair]?.relation !== "exact-rgba") reasons.push(`${pair}: exact assertion violated or comparison missing`); - if (e.pairs["chromium-control"]?.relation !== "different-rgba") + if ( + e.pairs["baked-control"]?.relation !== "different-rgba" || + (obligation === "engine-and-reference" && + e.pairs["chromium-control"]?.relation !== "different-rgba") + ) return { status: "UNRESOLVED", kind, diff --git a/packages/grida-reftest/svg-assertions/opacity-source.json b/packages/grida-reftest/svg-assertions/opacity-source.json index ee173f0f..13169dbb 100644 --- a/packages/grida-reftest/svg-assertions/opacity-source.json +++ b/packages/grida-reftest/svg-assertions/opacity-source.json @@ -1,9 +1,45 @@ { - "schema_version": 1, + "schema_version": 2, "profile": "static-self-contained-svg-v1", "capture": { "sha256": "069296201718c43d29efe356fbea893781b73250dca51de3b6d47468d74027b0", - "browser_version": "149.0.7827.55" + "browser_version": "149.0.7827.55", + "environment": { + "schema_version": 1, + "host": { + "platform": "darwin", + "arch": "arm64", + "release": "25.6.0", + "version": "Darwin Kernel Version 25.6.0: Fri Jul 31 19:16:43 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_VMAPPLE" + }, + "browser": { + "product": "HeadlessChrome/149.0.7827.55", + "revision": "@3188f8a607ae7e067593be8aab7f02d2451fec07", + "arch": "arm64", + "sha256": "11e393326c7d20a7c56641a7c65def33ea9c280da3b0b74cf8563b07989a0ee3" + }, + "raster": { + "feature_status": { + "2d_canvas": "unavailable_software", + "direct_rendering_display_compositor": "disabled_off_ok", + "gpu_compositing": "disabled_software", + "multiple_raster_threads": "disabled_off", + "opengl": "disabled_off", + "rasterization": "disabled_software", + "raw_draw": "disabled_off_ok", + "skia_graphite": "disabled_off", + "trees_in_viz": "disabled_off", + "video_decode": "disabled_software", + "video_encode": "disabled_software", + "webgl": "unavailable_software", + "webgpu": "unavailable_software", + "webnn": "unavailable_software" + }, + "gl_implementation": "(gl=egl-angle,angle=swiftshader)", + "gl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver-5.0.0)", + "gl_version": "OpenGL ES 3.0 (ANGLE 2.1.27736 git hash: ded782bca9d5)" + } + } }, "cases": [ { diff --git a/packages/grida-reftest/svg-assertions/pilot.json b/packages/grida-reftest/svg-assertions/pilot.json index 75ae64a7..1de19f4c 100644 --- a/packages/grida-reftest/svg-assertions/pilot.json +++ b/packages/grida-reftest/svg-assertions/pilot.json @@ -1,9 +1,45 @@ { - "schema_version": 1, + "schema_version": 2, "profile": "static-self-contained-svg-v1", "capture": { "sha256": "069296201718c43d29efe356fbea893781b73250dca51de3b6d47468d74027b0", - "browser_version": "149.0.7827.55" + "browser_version": "149.0.7827.55", + "environment": { + "schema_version": 1, + "host": { + "platform": "darwin", + "arch": "arm64", + "release": "25.6.0", + "version": "Darwin Kernel Version 25.6.0: Fri Jul 31 19:16:43 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_VMAPPLE" + }, + "browser": { + "product": "HeadlessChrome/149.0.7827.55", + "revision": "@3188f8a607ae7e067593be8aab7f02d2451fec07", + "arch": "arm64", + "sha256": "11e393326c7d20a7c56641a7c65def33ea9c280da3b0b74cf8563b07989a0ee3" + }, + "raster": { + "feature_status": { + "2d_canvas": "unavailable_software", + "direct_rendering_display_compositor": "disabled_off_ok", + "gpu_compositing": "disabled_software", + "multiple_raster_threads": "disabled_off", + "opengl": "disabled_off", + "rasterization": "disabled_software", + "raw_draw": "disabled_off_ok", + "skia_graphite": "disabled_off", + "trees_in_viz": "disabled_off", + "video_decode": "disabled_software", + "video_encode": "disabled_software", + "webgl": "unavailable_software", + "webgpu": "unavailable_software", + "webnn": "unavailable_software" + }, + "gl_implementation": "(gl=egl-angle,angle=swiftshader)", + "gl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver-5.0.0)", + "gl_version": "OpenGL ES 3.0 (ANGLE 2.1.27736 git hash: ded782bca9d5)" + } + } }, "cases": [ { diff --git a/packages/grida-reftest/svg-assertions/reference-environment.test.ts b/packages/grida-reftest/svg-assertions/reference-environment.test.ts new file mode 100644 index 00000000..7fccf11c --- /dev/null +++ b/packages/grida-reftest/svg-assertions/reference-environment.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { parseEnvironment, environmentProblem } from "./reference-environment"; + +const identity = { + schema_version: 1, + host: { + platform: "darwin", + arch: "arm64", + release: "25.5.0", + version: "kernel-build", + }, + browser: { + product: "test", + revision: "test", + arch: "arm64", + sha256: "a".repeat(64), + }, + raster: { + feature_status: { + rasterization: "disabled_software", + gpu_compositing: "disabled_software", + }, + gl_implementation: "test", + gl_renderer: "test", + gl_version: "test", + }, +}; +describe("declared reference environment", () => { + it("compares typed identity independent of JSON key order", () => { + const actual = structuredClone(identity); + actual.raster.feature_status = { + gpu_compositing: "disabled_software", + rasterization: "disabled_software", + }; + expect(environmentProblem(parseEnvironment(identity), actual)).toBeNull(); + }); + it.each(["host", "browser", "raster"] as const)( + "refuses changed %s even with matching pixels", + (key) => { + const actual = structuredClone(identity); + if (key === "host") actual.host.version = "other-kernel"; + if (key === "browser") actual.browser.arch = "x64"; + if (key === "raster") + actual.raster.feature_status.rasterization = "enabled_on"; + expect(environmentProblem(parseEnvironment(identity), actual)).toBe( + `reference-environment-mismatch: ${key}` + ); + } + ); + it("rejects missing, unknown, malformed, oversized or unrecognized identities", () => { + for (const actual of [ + null, + {}, + { ...identity, schema_version: 2 }, + { ...identity, optionalTypo: true }, + { ...identity, browser: { ...identity.browser, sha256: "unknown" } }, + { ...identity, host: { ...identity.host, arch: "a".repeat(8193) } }, + { ...identity, raster: { ...identity.raster, feature_status: {} } }, + { ...identity, raster: { ...identity.raster, gl_version: null } }, + ]) + expect(environmentProblem(parseEnvironment(identity), actual)).toContain( + "reference-environment-unavailable" + ); + }); +}); diff --git a/packages/grida-reftest/svg-assertions/reference-environment.ts b/packages/grida-reftest/svg-assertions/reference-environment.ts new file mode 100644 index 00000000..46da8257 --- /dev/null +++ b/packages/grida-reftest/svg-assertions/reference-environment.ts @@ -0,0 +1,96 @@ +/** A reference environment is an input, never inferred from matching pixels. + * No launcher, renderer selection, tolerance, or alternative reference lives here. */ +export interface ReferenceEnvironment { + schema_version: 1; + host: { platform: string; arch: string; release: string; version: string }; + browser: { product: string; revision: string; arch: string; sha256: string }; + raster: { + feature_status: Record; + gl_implementation: string; + gl_renderer: string; + gl_version: string; + }; +} + +function object(value: unknown, keys: string[]): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).length !== keys.length || + keys.some((k) => !(k in value)) + ) + throw new Error(`reference identity requires exactly: ${keys.join(", ")}`); + return value as Record; +} +function text(value: unknown): string { + if (typeof value !== "string" || !value.trim() || value.length > 8192) + throw new Error("reference identity requires bounded nonempty text"); + return value; +} +export function parseEnvironment(value: unknown): ReferenceEnvironment { + const e = object(value, ["schema_version", "host", "browser", "raster"]); + if (e.schema_version !== 1) + throw new Error("unknown reference environment version"); + const host = object(e.host, ["platform", "arch", "release", "version"]); + const browser = object(e.browser, ["product", "revision", "arch", "sha256"]); + const raster = object(e.raster, [ + "feature_status", + "gl_implementation", + "gl_renderer", + "gl_version", + ]); + const features = raster.feature_status; + if ( + !features || + typeof features !== "object" || + Array.isArray(features) || + !Object.keys(features).length || + Object.keys(features).length > 64 + ) + throw new Error("missing or unbounded raster feature identity"); + const sha256 = text(browser.sha256); + if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("invalid browser hash"); + return { + schema_version: 1, + host: { + platform: text(host.platform), + arch: text(host.arch), + release: text(host.release), + version: text(host.version), + }, + browser: { + product: text(browser.product), + revision: text(browser.revision), + arch: text(browser.arch), + sha256, + }, + raster: { + feature_status: Object.fromEntries( + Object.entries(features) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => [text(k), text(v)]) + ), + gl_implementation: text(raster.gl_implementation), + gl_renderer: text(raster.gl_renderer), + gl_version: text(raster.gl_version), + }, + }; +} + +/** The complete declared identity is matched, including kernel build text. + * Missing or extra identity fields fail closed. No architecture shortcut. */ +export function environmentProblem( + expected: ReferenceEnvironment, + observation: unknown +): string | null { + try { + const actual = parseEnvironment(observation); + for (const key of ["host", "browser", "raster"] as const) + if (JSON.stringify(actual[key]) !== JSON.stringify(expected[key])) + return `reference-environment-mismatch: ${key}`; + return null; + } catch (error) { + return `reference-environment-unavailable: ${String(error)}`; + } +} diff --git a/packages/grida-reftest/svg-assertions/report.ts b/packages/grida-reftest/svg-assertions/report.ts index c9606ba3..195f3bb7 100644 --- a/packages/grida-reftest/svg-assertions/report.ts +++ b/packages/grida-reftest/svg-assertions/report.ts @@ -23,6 +23,7 @@ export function renderReport(report: Report): string {

SVG assertion observations

One described claim, one discrete verdict. Image differences are evidence, not grades.

${report.gate_ready ? "Required assertions satisfied." : "Required assertion gate is NOT ready."} This is not an SVG support or conformance result.

+

Selected obligation: ${e(report.obligation)}. Engine-only: ${report.engine_ready ? "satisfied" : "NOT ready"}. Engine-only never certifies canonical reference reproduction.

Profile: ${e(report.manifest.profile)} · Chromium ${e(report.manifest.capture.browser_version)} · Raw observations

Run provenance: versions, hashes and build identity
${e(JSON.stringify(report.tools, null, 2))}
${report.integrity.length ? `
${e(report.integrity.join("\n"))}
` : ""} @@ -32,6 +33,7 @@ ${report.cases r ) => `

${e(r.case.id)}

${e(r.case.description ?? "Undescribed upstream observation")}

${e(r.verdict.status)} · ${r.verdict.kind === "refusal" ? "expected refusal assertion — NOT rendering support" : e(r.verdict.kind)} · ${r.case.required ? "required" : "exploratory"}

+

Engine-only: ${e(r.engine_verdict.status)}. Reference identity: ${e(r.reference_problem ?? "matches declared profile")}

${e(r.engine_verdict.reasons.join("\n"))}

${e(r.case.assertion?.decision ?? "No reference decision")}

${e(r.verdict.reasons.join("\n"))}
${["baked", "chromium", "strict", "best", "stored", "resvg"] .map((name) => { @@ -42,7 +44,7 @@ ${report.cases }) .join( "" - )}
Exact comparisons, diagnostics and both repeats
${e(JSON.stringify({ pairs: r.pairs, observations: r.observations }, null, 2))}
` + )}
Exact comparisons, diagnostics and both repeats
${e(JSON.stringify({ pairs: r.pairs, observations: r.observations, reference_identity: r.reference_identity }, null, 2))}
` ) .join("\n")}`; } diff --git a/packages/grida-reftest/svg-assertions/runner.ts b/packages/grida-reftest/svg-assertions/runner.ts index 01cd9cbd..25bcde35 100644 --- a/packages/grida-reftest/svg-assertions/runner.ts +++ b/packages/grida-reftest/svg-assertions/runner.ts @@ -19,11 +19,13 @@ import { type Image, type ImageRecord, type Observation, + type Obligation, type Sample, type Suite, type Verdict, } from "./model"; import { renderReport } from "./report"; +import { environmentProblem } from "./reference-environment"; export const toolDir = dirname(fileURLToPath(import.meta.url)); export const repoDir = resolve(toolDir, "../../.."); @@ -224,9 +226,12 @@ export interface CaseResult { observations: Record; pairs: Record; verdict: Verdict; + engine_verdict: Verdict; + reference_identity: unknown; + reference_problem: string | null; } export interface Report { - schema_version: 1; + schema_version: 2; kind: "svg-assertion-observations"; manifest: Suite; manifest_sha256: string; @@ -235,10 +240,13 @@ export interface Report { integrity: string[]; cases: CaseResult[]; gate_ready: boolean; + engine_ready: boolean; + obligation: Obligation; } export interface Options { manifest: string; out: string; + obligation?: Obligation; resvg?: { executable: string; sha256: string; version: string }; } @@ -267,12 +275,14 @@ export async function run(options: Options): Promise { }; for (const path of [ capturePath, + join(repoDir, "pnpm-lock.yaml"), ...[ "model.ts", "runner.ts", "report.ts", "capture-worker.ts", "capture-identity.ts", + "reference-environment.ts", "cli.ts", ].map((name) => join(toolDir, name)), ]) { @@ -420,6 +430,13 @@ export async function run(options: Options): Promise { observations, pairs: {}, verdict: { status: "OBSERVATION", kind: "observation", reasons: [] }, + engine_verdict: { + status: "OBSERVATION", + kind: "observation", + reasons: [], + }, + reference_identity: null, + reference_problem: "reference-environment-unavailable", }; results.push(result); const sourcePath = join(out, c.id, "source.svg"); @@ -519,6 +536,19 @@ export async function run(options: Options): Promise { ) ).record ); + try { + const identityPath = join(out, c.id, "chromium-identity.json"); + const bytes = await readBounded(identityPath, 65536); + watched.set(identityPath, sha256(bytes)); + inputLimits.set(identityPath, 65536); + result.reference_identity = JSON.parse(bytes.toString("utf8")); + result.reference_problem = environmentProblem( + suite.capture.environment, + result.reference_identity + ); + } catch (error) { + result.reference_problem = `reference-environment-unavailable: ${String(error)}`; + } } const resvg: Observation = { samples: [], problem: null }; observations.resvg = resvg; @@ -560,37 +590,42 @@ export async function run(options: Options): Promise { if (c.assertion?.kind === "render-exact") { const id = c.assertion.control.case, control = results.find((r) => r.case.id === id); - const a = result.observations.chromium.samples[0]?.image, - b = control?.observations.chromium.samples[0]?.image; - if ( - a && - b && - control && - !control.problems.length && - control.case.review.status === "reviewed" && - !control.case.review.blockers.length && - !repeatProblem(control.observations.chromium) && - control.observations.chromium.samples.every( - (s) => - successful(s.execution) && !s.diagnostics && !s.execution.stdout - ) - ) { - try { - const reload = async (record: ImageRecord): Promise => { - const bytes = await readBounded( - join(out, record.path), - MAX_PNG_BYTES + for (const label of ["baked", "chromium"] as const) { + const a = result.observations[label].samples[0]?.image, + b = control?.observations[label].samples[0]?.image; + if ( + a && + b && + control && + !control.problems.length && + control.case.review.status === "reviewed" && + !control.case.review.blockers.length && + (label === "baked" || + (!repeatProblem(control.observations.chromium) && + control.reference_problem === null)) && + !control.observations[label].problem && + control.observations[label].samples.every( + (s) => + successful(s.execution) && !s.diagnostics && !s.execution.stdout + ) + ) { + try { + const reload = async (record: ImageRecord): Promise => { + const bytes = await readBounded( + join(out, record.path), + MAX_PNG_BYTES + ); + if (sha256(bytes) !== record.png_sha256) + throw new Error(`control output changed: ${record.path}`); + return decode(bytes); + }; + result.pairs[`${label}-control`] = compare( + await reload(a), + await reload(b) ); - if (sha256(bytes) !== record.png_sha256) - throw new Error(`control output changed: ${record.path}`); - return decode(bytes); - }; - result.pairs["chromium-control"] = compare( - await reload(a), - await reload(b) - ); - } catch (error) { - result.problems.push(String(error)); + } catch (error) { + result.problems.push(String(error)); + } } } } @@ -613,19 +648,22 @@ export async function run(options: Options): Promise { } } for (const result of results) { - result.verdict = evaluate(result.case, { + const evidence = { problems: [...integrity, ...result.problems], strict: result.observations.strict, best: result.observations.best, chromium: result.observations.chromium, + reference_problem: result.reference_problem, pairs: result.pairs, - }); + }; + result.verdict = evaluate(result.case, evidence); + result.engine_verdict = evaluate(result.case, evidence, "engine"); } const verdicts = Object.fromEntries( results.map((r) => [r.case.id, r.verdict]) ); const report: Report = { - schema_version: 1, + schema_version: 2, kind: "svg-assertion-observations", manifest: suite, manifest_sha256: sha256(manifestBytes), @@ -634,6 +672,12 @@ export async function run(options: Options): Promise { integrity, cases: results, gate_ready: gateReady(suite.cases, verdicts, integrity), + engine_ready: gateReady( + suite.cases, + Object.fromEntries(results.map((r) => [r.case.id, r.engine_verdict])), + integrity + ), + obligation: options.obligation ?? "engine-and-reference", }; await write("report.json", `${JSON.stringify(report, null, 2)}\n`); await write("index.html", renderReport(report));