From 75c13f142291d867ca9020e590e9f63a2f0d45e9 Mon Sep 17 00:00:00 2001 From: sagudev <16504129+sagudev@users.noreply.github.com> Date: Fri, 11 Jul 2025 08:49:09 +0200 Subject: [PATCH] canvas: Properly bound all image pattern axis by inserting clip (#37668) Before we only handled no-repeat for rect, this means we rendered https://sagudev.github.io/briefcase/no-repeat.html incorrectly (like firefox). Now if one of the axis is bounded (does not repeat) we clip it and let other axis be unbounded (technically we clip it to end of canvas). This is also needed for vello backend. Testing: Tests in WPT exists and another test is added. --------- Signed-off-by: sagudev <16504129+sagudev@users.noreply.github.com> Co-authored-by: Martin Robinson --- components/canvas/backend.rs | 6 +- components/canvas/canvas_data.rs | 137 +++++++++++++----- components/canvas/raqote_backend.rs | 77 ++++++---- tests/wpt/meta/MANIFEST.json | 23 ++- ...2d.pattern.paint.norepeat.outside_arc.html | 44 ++++++ ...2d.pattern.paint.norepeat.outside_arc.html | 40 +++++ ...ttern.paint.norepeat.outside_arc.worker.js | 33 +++++ .../tools/yaml/fill-and-stroke-styles.yaml | 24 +++ 8 files changed, 314 insertions(+), 70 deletions(-) create mode 100644 tests/wpt/tests/html/canvas/element/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.html create mode 100644 tests/wpt/tests/html/canvas/offscreen/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.html create mode 100644 tests/wpt/tests/html/canvas/offscreen/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.worker.js diff --git a/components/canvas/backend.rs b/components/canvas/backend.rs index 5da67feaf3f..78c48efc8c7 100644 --- a/components/canvas/backend.rs +++ b/components/canvas/backend.rs @@ -98,6 +98,7 @@ pub(crate) trait GenericDrawTarget { fn get_transform(&self) -> Transform2D; fn pop_clip(&mut self); fn push_clip(&mut self, path: &B::Path); + fn push_clip_rect(&mut self, rect: &Rect); fn set_transform(&mut self, matrix: &Transform2D); fn stroke( &mut self, @@ -309,11 +310,13 @@ pub(crate) trait GenericPath> { } } } + fn bounding_box(&self) -> Rect; } pub(crate) trait PatternHelpers { fn is_zero_size_gradient(&self) -> bool; - fn draw_rect(&self, rect: &Rect) -> Rect; + fn x_bound(&self) -> Option; + fn y_bound(&self) -> Option; } pub(crate) trait StrokeOptionsHelpers { @@ -327,4 +330,5 @@ pub(crate) trait StrokeOptionsHelpers { pub(crate) trait DrawOptionsHelpers { fn set_alpha(&mut self, val: f32); + fn is_clear(&self) -> bool; } diff --git a/components/canvas/canvas_data.rs b/components/canvas/canvas_data.rs index 87122aa4fc6..77755265c2c 100644 --- a/components/canvas/canvas_data.rs +++ b/components/canvas/canvas_data.rs @@ -492,11 +492,17 @@ impl<'a, B: Backend> CanvasData<'a, B> { // > from left to right (if any), adding to the array, for each glyph, the shape of the glyph // > as it is in the inline box, positioned on a coordinate space using CSS pixels with its // > origin is at the anchor point. - self.drawtarget.fill_text( - shaped_runs, - start, - &self.state.fill_style, - &self.state.draw_options, + self.maybe_bound_shape_with_pattern( + self.state.fill_style.clone(), + &Rect::from_size(Size2D::new(total_advance, size)), + |self_| { + self_.drawtarget.fill_text( + shaped_runs, + start, + &self_.state.fill_style, + &self_.state.draw_options, + ); + }, ); } @@ -676,19 +682,22 @@ impl<'a, B: Backend> CanvasData<'a, B> { return; // Paint nothing if gradient size is zero. } - let draw_rect = self.state.fill_style.draw_rect(rect); - if self.need_to_draw_shadow() { - self.draw_with_shadow(&draw_rect, |new_draw_target: &mut B::DrawTarget| { - new_draw_target.fill_rect( - &draw_rect, - &self.state.fill_style, - &self.state.draw_options, - ); + self.draw_with_shadow(rect, |new_draw_target: &mut B::DrawTarget| { + new_draw_target.fill_rect(rect, &self.state.fill_style, &self.state.draw_options); }); } else { - self.drawtarget - .fill_rect(&draw_rect, &self.state.fill_style, &self.state.draw_options); + self.maybe_bound_shape_with_pattern( + self.state.fill_style.clone(), + &rect.cast(), + |self_| { + self_.drawtarget.fill_rect( + rect, + &self_.state.fill_style, + &self_.state.draw_options, + ); + }, + ); } } @@ -711,12 +720,18 @@ impl<'a, B: Backend> CanvasData<'a, B> { ); }); } else { - self.drawtarget.stroke_rect( - rect, - &self.state.stroke_style, - &self.state.stroke_opts, - &self.state.draw_options, - ); + self.maybe_bound_shape_with_pattern( + self.state.stroke_style.clone(), + &rect.cast(), + |self_| { + self_.drawtarget.stroke_rect( + rect, + &self_.state.stroke_style, + &self_.state.stroke_opts, + &self_.state.draw_options, + ); + }, + ) } } @@ -781,11 +796,17 @@ impl<'a, B: Backend> CanvasData<'a, B> { return; // Path is uninvertible. }; - self.drawtarget.fill( - &path, - &self.state.fill_style, - &self.state.draw_options.clone(), - ); + self.maybe_bound_shape_with_pattern( + self.state.fill_style.clone(), + &path.bounding_box(), + |self_| { + self_.drawtarget.fill( + &path, + &self_.state.fill_style, + &self_.state.draw_options.clone(), + ); + }, + ) } pub(crate) fn fill_path(&mut self, path_segments: &[PathSegment]) { @@ -809,12 +830,18 @@ impl<'a, B: Backend> CanvasData<'a, B> { return; // Path is uninvertible. }; - self.drawtarget.stroke( - &path, - &self.state.stroke_style, - &self.state.stroke_opts, - &self.state.draw_options, - ); + self.maybe_bound_shape_with_pattern( + self.state.stroke_style.clone(), + &path.bounding_box(), + |self_| { + self_.drawtarget.stroke( + &path, + &self_.state.stroke_style, + &self_.state.stroke_opts, + &self_.state.draw_options, + ); + }, + ) } pub(crate) fn stroke_path(&mut self, path_segments: &[PathSegment]) { @@ -825,12 +852,18 @@ impl<'a, B: Backend> CanvasData<'a, B> { let mut path = B::Path::new(); path.add_segments(path_segments); - self.drawtarget.stroke( - &path, - &self.state.stroke_style, - &self.state.stroke_opts, - &self.state.draw_options, - ); + self.maybe_bound_shape_with_pattern( + self.state.stroke_style.clone(), + &path.bounding_box(), + |self_| { + self_.drawtarget.stroke( + &path, + &self_.state.stroke_style, + &self_.state.stroke_opts, + &self_.state.draw_options, + ); + }, + ) } pub(crate) fn clip(&mut self) { @@ -1183,6 +1216,34 @@ impl<'a, B: Backend> CanvasData<'a, B> { ); } + /// Push a clip to the draw target to respect the non-repeating bound (either x, y, or both) + /// of the given pattern. + fn maybe_bound_shape_with_pattern( + &mut self, + pattern: B::Pattern<'_>, + path_bound_box: &Rect, + draw_shape: F, + ) where + F: FnOnce(&mut Self), + { + let x_bound = pattern.x_bound(); + let y_bound = pattern.y_bound(); + // Clear operations are also unbounded. + if self.state.draw_options.is_clear() || (x_bound.is_none() && y_bound.is_none()) { + draw_shape(self); + return; + } + let rect = Rect::from_size(Size2D::new( + x_bound.unwrap_or(path_bound_box.size.width.ceil() as u32), + y_bound.unwrap_or(path_bound_box.size.height.ceil() as u32), + )) + .cast(); + let rect = self.get_transform().outer_transformed_rect(&rect); + self.drawtarget.push_clip_rect(&rect.cast()); + draw_shape(self); + self.drawtarget.pop_clip(); + } + /// It reads image data from the canvas /// canvas_size: The size of the canvas we're reading from /// read_rect: The area of the canvas we want to read from diff --git a/components/canvas/raqote_backend.rs b/components/canvas/raqote_backend.rs index 20c57408fff..94a0fc6db9f 100644 --- a/components/canvas/raqote_backend.rs +++ b/components/canvas/raqote_backend.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use canvas_traits::canvas::*; use compositing_traits::SerializableImageData; use cssparser::color::clamp_unit_f32; -use euclid::default::{Point2D, Rect, Size2D, Transform2D, Vector2D}; +use euclid::default::{Box2D, Point2D, Rect, Size2D, Transform2D, Vector2D}; use font_kit::font::Font; use fonts::{ByteIndex, FontIdentifier, FontTemplateRefMethods}; use ipc_channel::ipc::IpcSharedMemory; @@ -197,15 +197,13 @@ impl SurfacePattern { transform, } } - pub fn size(&self) -> Size2D { - self.image.size().cast() - } + pub fn repetition(&self) -> &Repetition { &self.repeat } } -#[derive(Clone)] +#[derive(Clone, Copy, Debug)] pub enum Repetition { Repeat, RepeatX, @@ -284,33 +282,23 @@ impl PatternHelpers for Pattern { } } - fn draw_rect(&self, rect: &Rect) -> Rect { + fn x_bound(&self) -> Option { match self { - Pattern::Surface(pattern) => { - let pattern_rect = Rect::new(Point2D::origin(), pattern.size()); - let mut draw_rect = rect.intersection(&pattern_rect).unwrap_or(Rect::zero()); - - match pattern.repetition() { - Repetition::NoRepeat => { - draw_rect.size.width = draw_rect.size.width.min(pattern_rect.size.width); - draw_rect.size.height = draw_rect.size.height.min(pattern_rect.size.height); - }, - Repetition::RepeatX => { - draw_rect.size.width = rect.size.width; - draw_rect.size.height = draw_rect.size.height.min(pattern_rect.size.height); - }, - Repetition::RepeatY => { - draw_rect.size.height = rect.size.height; - draw_rect.size.width = draw_rect.size.width.min(pattern_rect.size.width); - }, - Repetition::Repeat => { - draw_rect = *rect; - }, - } - - draw_rect + Pattern::Surface(pattern) => match pattern.repetition() { + Repetition::RepeatX | Repetition::Repeat => None, // x is not bounded + Repetition::RepeatY | Repetition::NoRepeat => Some(pattern.image.size().width), }, - Pattern::Color(..) | Pattern::LinearGradient(..) | Pattern::RadialGradient(..) => *rect, + Pattern::Color(..) | Pattern::LinearGradient(..) | Pattern::RadialGradient(..) => None, + } + } + + fn y_bound(&self) -> Option { + match self { + Pattern::Surface(pattern) => match pattern.repetition() { + Repetition::RepeatY | Repetition::Repeat => None, // y is not bounded + Repetition::RepeatX | Repetition::NoRepeat => Some(pattern.image.size().height), + }, + Pattern::Color(..) | Pattern::LinearGradient(..) | Pattern::RadialGradient(..) => None, } } } @@ -340,6 +328,10 @@ impl DrawOptionsHelpers for raqote::DrawOptions { fn set_alpha(&mut self, val: f32) { self.alpha = val; } + + fn is_clear(&self) -> bool { + matches!(self.blend_mode, raqote::BlendMode::Clear) + } } fn create_gradient_stops(gradient_stops: Vec) -> Vec { @@ -576,6 +568,9 @@ impl GenericDrawTarget for raqote::DrawTarget { fn push_clip(&mut self, path: &::Path) { self.push_clip(&path.into()); } + fn push_clip_rect(&mut self, rect: &Rect) { + self.push_clip_rect(rect.to_box2d()); + } fn set_transform(&mut self, matrix: &Transform2D) { self.set_transform(matrix); } @@ -730,6 +725,28 @@ impl GenericPath for Path { let path: raqote::Path = (&*self).into(); self.0.set(path.transform(transform).into()); } + + fn bounding_box(&self) -> Rect { + let path: raqote::Path = self.into(); + let mut points = vec![]; + for op in path.ops { + match op { + PathOp::MoveTo(p) => points.push(p), + PathOp::LineTo(p) => points.push(p), + PathOp::QuadTo(p1, p2) => { + points.push(p1); + points.push(p2); + }, + PathOp::CubicTo(p1, p2, p3) => { + points.push(p1); + points.push(p2); + points.push(p3); + }, + PathOp::Close => {}, + } + } + Box2D::from_points(points).to_rect().cast() + } } fn get_first_point(op: &PathOp) -> Option> { diff --git a/tests/wpt/meta/MANIFEST.json b/tests/wpt/meta/MANIFEST.json index 38bbb5c06bc..547dfe75822 100644 --- a/tests/wpt/meta/MANIFEST.json +++ b/tests/wpt/meta/MANIFEST.json @@ -478477,7 +478477,7 @@ [] ], "fill-and-stroke-styles.yaml": [ - "f2888e648f42bd052fdc3f38c317866c7f153824", + "32402680bf14ee87815aafee0b33af3703c71c3b", [] ], "filters.yaml": [ @@ -703792,6 +703792,13 @@ {} ] ], + "2d.pattern.paint.norepeat.outside_arc.html": [ + "73159d1bbc6e40a1c361e393bd1b0ea33e30478c", + [ + null, + {} + ] + ], "2d.pattern.paint.orientation.canvas.html": [ "b68e04272cd62933a1f09da984adda4746a48e1a", [ @@ -714584,6 +714591,20 @@ {} ] ], + "2d.pattern.paint.norepeat.outside_arc.html": [ + "9052f14e5b501a98c82538504911013e08913bef", + [ + null, + {} + ] + ], + "2d.pattern.paint.norepeat.outside_arc.worker.js": [ + "96e27da06b0714ae27d1414908a5b320396fc84a", + [ + "html/canvas/offscreen/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.worker.html", + {} + ] + ], "2d.pattern.paint.orientation.canvas.html": [ "3fa8a96a4ad8a17559c613c761eb30a34b71445e", [ diff --git a/tests/wpt/tests/html/canvas/element/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.html b/tests/wpt/tests/html/canvas/element/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.html new file mode 100644 index 00000000000..73159d1bbc6 --- /dev/null +++ b/tests/wpt/tests/html/canvas/element/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.html @@ -0,0 +1,44 @@ + + + +Canvas test: 2d.pattern.paint.norepeat.outside_arc + + + + + + +

2d.pattern.paint.norepeat.outside_arc

+

+ + +

Actual output:

+

FAIL (fallback content)

+

Expected output:

+

    + + + diff --git a/tests/wpt/tests/html/canvas/offscreen/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.html b/tests/wpt/tests/html/canvas/offscreen/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.html new file mode 100644 index 00000000000..9052f14e5b5 --- /dev/null +++ b/tests/wpt/tests/html/canvas/offscreen/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.html @@ -0,0 +1,40 @@ + + + +OffscreenCanvas test: 2d.pattern.paint.norepeat.outside_arc + + + + +

    2d.pattern.paint.norepeat.outside_arc

    +

    + + + diff --git a/tests/wpt/tests/html/canvas/offscreen/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.worker.js b/tests/wpt/tests/html/canvas/offscreen/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.worker.js new file mode 100644 index 00000000000..96e27da06b0 --- /dev/null +++ b/tests/wpt/tests/html/canvas/offscreen/fill-and-stroke-styles/2d.pattern.paint.norepeat.outside_arc.worker.js @@ -0,0 +1,33 @@ +// DO NOT EDIT! This test has been generated by /html/canvas/tools/gentest.py. +// OffscreenCanvas test in a worker:2d.pattern.paint.norepeat.outside_arc +// Description: +// Note: + +importScripts("/resources/testharness.js"); +importScripts("/html/canvas/resources/canvas-tests.js"); + +promise_test(async t => { + var canvas = new OffscreenCanvas(100, 50); + var ctx = canvas.getContext('2d'); + + ctx.fillStyle = '#f00'; + ctx.fillRect(0, 0, 100, 50); + var response = await fetch('/images/red-16x16.png') + var blob = await response.blob(); + var img = await createImageBitmap(blob); + var pattern = ctx.createPattern(img, 'no-repeat'); + ctx.fillStyle = '#0f0'; + ctx.fillRect(0, 0, 100, 50); + ctx.fillStyle = pattern; + ctx.beginPath(); + ctx.arc(0, 0, 50, 0, Math.PI * 2); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#0f0'; + ctx.fillRect(0, 0, 16, 16); + _assertPixel(canvas, 1,1, 0,255,0,255); + _assertPixel(canvas, 20,1, 0,255,0,255); + _assertPixel(canvas, 1,20, 0,255,0,255); + _assertPixel(canvas, 48,48, 0,255,0,255); +}, ""); +done(); diff --git a/tests/wpt/tests/html/canvas/tools/yaml/fill-and-stroke-styles.yaml b/tests/wpt/tests/html/canvas/tools/yaml/fill-and-stroke-styles.yaml index f2888e648f4..32402680bf1 100644 --- a/tests/wpt/tests/html/canvas/tools/yaml/fill-and-stroke-styles.yaml +++ b/tests/wpt/tests/html/canvas/tools/yaml/fill-and-stroke-styles.yaml @@ -1867,6 +1867,30 @@ expected: green variants: *load-image-variant-definition +- name: 2d.pattern.paint.norepeat.outside_arc + images: + - red-16x16.png + code: | + ctx.fillStyle = '#f00'; + ctx.fillRect(0, 0, 100, 50); + {{ load_image }} + var pattern = ctx.createPattern(img, 'no-repeat'); + ctx.fillStyle = '#0f0'; + ctx.fillRect(0, 0, 100, 50); + ctx.fillStyle = pattern; + ctx.beginPath(); + ctx.arc(0, 0, 50, 0, Math.PI * 2); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#0f0'; + ctx.fillRect(0, 0, 16, 16); + @assert pixel 1,1 == 0,255,0,255; + @assert pixel 20,1 == 0,255,0,255; + @assert pixel 1,20 == 0,255,0,255; + @assert pixel 48,48 == 0,255,0,255; + expected: green + variants: *load-image-variant-definition + - name: 2d.pattern.paint.norepeat.coord1 images: - green.png