From 6f142c21faca439b6cb1ac5c2db183974adad0b8 Mon Sep 17 00:00:00 2001 From: Mike Welsh Date: Sun, 17 Jul 2022 14:46:01 -0700 Subject: [PATCH] canvas: Fix transforming of gradient/bitmap strokes --- render/canvas/src/lib.rs | 505 ++++++++++++++++++++++++--------------- 1 file changed, 316 insertions(+), 189 deletions(-) diff --git a/render/canvas/src/lib.rs b/render/canvas/src/lib.rs index 32a655dce..8defc70a0 100644 --- a/render/canvas/src/lib.rs +++ b/render/canvas/src/lib.rs @@ -35,6 +35,24 @@ struct ShapeData(Vec); struct CanvasColor(String, u8, u8, u8, u8); +impl From<&Color> for CanvasColor { + fn from(color: &Color) -> CanvasColor { + CanvasColor( + format!( + "rgba({},{},{},{})", + color.r, + color.g, + color.b, + f32::from(color.a) / 255.0 + ), + color.r, + color.g, + color.b, + color.a, + ) + } +} + impl CanvasColor { /// Apply a color transformation to this color. fn color_transform(&self, cxform: &ColorTransform) -> Self { @@ -54,7 +72,7 @@ enum CanvasDrawCommand { Stroke { path: Path2d, line_width: f64, - stroke_style: CanvasFillStyle, + stroke_style: CanvasStrokeStyle, line_cap: String, line_join: String, miter_limit: f64, @@ -68,17 +86,41 @@ enum CanvasDrawCommand { }, } +/// Fill style for a canvas path. enum CanvasFillStyle { Color(CanvasColor), - Gradient(CanvasGradient), - TransformedGradient(TransformedGradient), - Pattern(CanvasPattern, bool), + Gradient(Gradient), + Bitmap(CanvasBitmap), } -struct TransformedGradient { +struct Gradient { gradient: CanvasGradient, - gradient_matrix: [f64; 6], - inverse_gradient_matrix: DomMatrix, + transform: Option, +} + +/// A "complex" gradient transform, such as elliptical or skewed gradients. +/// +/// Canvas does not provide an API for arbitrary gradient transforms, so we cheat by applying the +/// inverse of the gradient transform to the path, and then drawing the path with the gradient transform. +/// This results in an non-transformed shape with a transformed gradient. +struct GradientTransform { + matrix: [f64; 6], + inverse_matrix: DomMatrix, +} + +/// Stroke style for a canvas path. +/// +/// Gradients are handled differently for strokes vs. fills. +enum CanvasStrokeStyle { + Color(CanvasColor), + Gradient(swf::Gradient, Option), + Bitmap(CanvasBitmap), +} + +struct CanvasBitmap { + pattern: CanvasPattern, + matrix: Matrix, + smoothed: bool, } #[allow(dead_code)] @@ -386,73 +428,76 @@ impl RenderBackend for WebCanvasRenderBackend { MaskState::DrawContent => { let mut line_scale = LineScales::new(&transform.matrix); let dom_matrix = transform.matrix.to_dom_matrix(); - self.set_transform(&transform.matrix); + let mut transform_dirty = true; if let Some(shape) = self.shapes.get(shape.0) { for command in shape.0.iter() { match command { - CanvasDrawCommand::Fill { path, fill_style } => match fill_style { - CanvasFillStyle::Color(color) => { - let color = color.color_transform(&transform.color_transform); - self.context.set_fill_style(&color.0.into()); - self.context.fill_with_path_2d_and_winding( - path, - CanvasWindingRule::Evenodd, + CanvasDrawCommand::Fill { path, fill_style } => { + if transform_dirty { + let _ = self.context.set_transform( + transform.matrix.a.into(), + transform.matrix.b.into(), + transform.matrix.c.into(), + transform.matrix.d.into(), + transform.matrix.tx.to_pixels(), + transform.matrix.ty.to_pixels(), ); + transform_dirty = false; } - CanvasFillStyle::Gradient(gradient) => { - self.set_color_filter(transform); - self.context.set_fill_style(gradient); - self.context.fill_with_path_2d_and_winding( - path, - CanvasWindingRule::Evenodd, - ); - self.clear_color_filter(); + match fill_style { + CanvasFillStyle::Color(color) => { + let color = + color.color_transform(&transform.color_transform); + self.context.set_fill_style(&color.0.into()); + self.context.fill_with_path_2d_and_winding( + path, + CanvasWindingRule::Evenodd, + ); + } + CanvasFillStyle::Gradient(gradient) => { + self.set_color_filter(transform); + self.context.set_fill_style(&gradient.gradient); + + if let Some(gradient_transform) = &gradient.transform { + // Canvas has no easy way to draw gradients with an arbitrary transform, + // but we can fake it by pushing the gradient's transform to the canvas, + // then transforming the path itself by the inverse. + let matrix = &gradient_transform.matrix; + let _ = self.context.transform( + matrix[0], matrix[1], matrix[2], matrix[3], + matrix[4], matrix[5], + ); + transform_dirty = true; + let untransformed_path = Path2d::new().unwrap(); + untransformed_path.add_path_with_transformation( + path, + gradient_transform.inverse_matrix.unchecked_ref(), + ); + self.context.fill_with_path_2d_and_winding( + &untransformed_path, + CanvasWindingRule::Evenodd, + ); + } else { + self.context.fill_with_path_2d_and_winding( + path, + CanvasWindingRule::Evenodd, + ); + } + + self.clear_color_filter(); + } + CanvasFillStyle::Bitmap(bitmap) => { + self.set_color_filter(transform); + self.context.set_image_smoothing_enabled(bitmap.smoothed); + self.context.set_fill_style(&bitmap.pattern); + self.context.fill_with_path_2d_and_winding( + path, + CanvasWindingRule::Evenodd, + ); + self.clear_color_filter(); + } } - CanvasFillStyle::TransformedGradient(gradient) => { - // Canvas has no easy way to draw gradients with an arbitrary transform, - // but we can fake it by pushing the gradient's transform to the canvas, - // then transforming the path itself by the inverse. - self.set_color_filter(transform); - self.context.set_fill_style(&gradient.gradient); - let matrix = &gradient.gradient_matrix; - self.context - .transform( - matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], - matrix[5], - ) - .warn_on_error(); - let untransformed_path = Path2d::new().unwrap(); - untransformed_path.add_path_with_transformation( - path, - gradient.inverse_gradient_matrix.unchecked_ref(), - ); - self.context.fill_with_path_2d_and_winding( - &untransformed_path, - CanvasWindingRule::Evenodd, - ); - self.context - .set_transform( - transform.matrix.a.into(), - transform.matrix.b.into(), - transform.matrix.c.into(), - transform.matrix.d.into(), - transform.matrix.tx.to_pixels(), - transform.matrix.ty.to_pixels(), - ) - .unwrap(); - self.clear_color_filter(); - } - CanvasFillStyle::Pattern(patt, smoothed) => { - self.set_color_filter(transform); - self.context.set_image_smoothing_enabled(*smoothed); - self.context.set_fill_style(patt); - self.context.fill_with_path_2d_and_winding( - path, - CanvasWindingRule::Evenodd, - ); - self.clear_color_filter(); - } - }, + } CanvasDrawCommand::Stroke { path, line_width, @@ -466,6 +511,7 @@ impl RenderBackend for WebCanvasRenderBackend { // Instead, reset the canvas transform, and apply the transform to the stroke path directly so // that the geometry remains untransformed. let _ = self.context.reset_transform(); + transform_dirty = true; let transformed_path = Path2d::new().unwrap(); transformed_path .add_path_with_transformation(path, dom_matrix.unchecked_ref()); @@ -478,41 +524,60 @@ impl RenderBackend for WebCanvasRenderBackend { line_scale.transform_width(*line_width as f32, *scale_mode); self.context.set_line_width(line_width.into()); match stroke_style { - CanvasFillStyle::Color(color) => { + CanvasStrokeStyle::Color(color) => { let color = color.color_transform(&transform.color_transform); self.context.set_stroke_style(&color.0.into()); self.context.stroke_with_path(&transformed_path); } - CanvasFillStyle::Gradient(gradient) => { - self.set_color_filter(transform); - self.context.set_stroke_style(gradient); - self.context.stroke_with_path(&transformed_path); - self.clear_color_filter(); + CanvasStrokeStyle::Gradient(gradient, focal_point) => { + // This is the hard case -- the Canvas API provides no good way to transform gradients, + // and the inverse-transform trick used above for gradient fills can't be used here + // because it will distort the stroke geometry. + // Another possibility is to avoid the Path2D API, instead drawing using explicit path + // commands (`context.lineTo`), then push the gradient transform, and finally stroke + // the path using `stroke()`. But this will be tons of JS calls if there are many strokes. + // So let's settle for allocating a new canvas gradient that is a best-effort match of the + // the desired transform. This will not match Flash exactly, but should be relatively rare. + let mut gradient = gradient.clone(); + gradient.matrix = (transform.matrix + * Matrix::from(gradient.matrix)) + .into(); + let gradient = match focal_point { + Some(focal_point) => create_radial_gradient( + &self.context, + &gradient, + *focal_point, + false, + ), + None => create_linear_gradient( + &self.context, + &gradient, + false, + ), + }; + if let Ok(gradient) = gradient { + self.set_color_filter(transform); + self.context.set_stroke_style(&gradient.gradient); + self.context.stroke_with_path(&transformed_path); + self.clear_color_filter(); + } } - CanvasFillStyle::TransformedGradient(gradient) => { + CanvasStrokeStyle::Bitmap(bitmap) => { + // Set the CanvasPattern's matrix to the concatenated transform. + let bitmap_matrix = transform.matrix + * bitmap.matrix + * Matrix::scale(0.05, 0.05); + bitmap.pattern.set_transform( + bitmap_matrix.to_dom_matrix().unchecked_ref(), + ); self.set_color_filter(transform); - self.context.set_stroke_style(&gradient.gradient); - self.context.stroke_with_path(&transformed_path); - self.clear_color_filter(); - } - CanvasFillStyle::Pattern(patt, smoothed) => { - self.context.set_image_smoothing_enabled(*smoothed); - self.context.set_stroke_style(patt); + self.context.set_image_smoothing_enabled(bitmap.smoothed); + self.context.set_stroke_style(&bitmap.pattern); self.context.stroke_with_path(&transformed_path); self.clear_color_filter(); } }; - self.context - .set_transform( - transform.matrix.a.into(), - transform.matrix.b.into(), - transform.matrix.c.into(), - transform.matrix.d.into(), - transform.matrix.tx.to_pixels(), - transform.matrix.ty.to_pixels(), - ) - .unwrap(); } } } @@ -700,85 +765,105 @@ fn swf_shape_to_canvas_commands( bounds_viewbox_matrix.set_d(1.0 / 20.0); for path in &shape.paths { - let (style, commands, is_fill, is_closed) = match &path { - DrawPath::Fill { - style, commands, .. - } => (*style, commands, true, false), - DrawPath::Stroke { - style, - commands, - is_closed, - } => (style.fill_style(), commands, false, *is_closed), - }; - let fill_style = match style { - FillStyle::Color(Color { r, g, b, a }) => CanvasFillStyle::Color(CanvasColor( - format!("rgba({},{},{},{})", r, g, b, f32::from(*a) / 255.0), - *r, - *g, - *b, - *a, - )), - FillStyle::LinearGradient(gradient) => { - create_linear_gradient(context, gradient, is_fill).unwrap() - } - FillStyle::RadialGradient(gradient) => { - create_radial_gradient(context, gradient, 0.0, is_fill).unwrap() - } - FillStyle::FocalGradient { - gradient, - focal_point, - } => create_radial_gradient(context, gradient, focal_point.to_f64(), is_fill).unwrap(), - FillStyle::Bitmap { - id, - matrix, - is_smoothed, - is_repeating, - } => { - if let Some(bitmap) = bitmap_source - .bitmap(*id) - .and_then(|bitmap| bitmaps.get(&bitmap.handle)) - { - let repeat = if !*is_repeating { - // NOTE: The WebGL backend does clamping in this case, just like - // Flash Player, but CanvasPattern has no such option... - "no-repeat" - } else { - "repeat" - }; - - let bitmap_pattern = if let Ok(Some(bitmap_pattern)) = - context.create_pattern_with_html_canvas_element(&bitmap.canvas, repeat) - { - bitmap_pattern - } else { - log::warn!("Unable to create bitmap pattern for bitmap ID {}", id); - continue; - }; - - bitmap_pattern.set_transform(matrix.to_dom_matrix().unchecked_ref()); - - CanvasFillStyle::Pattern(bitmap_pattern, *is_smoothed) - } else { - log::error!("Couldn't fill shape with unknown bitmap {}", id); - CanvasFillStyle::Color(CanvasColor("rgba(0,0,0,0)".to_string(), 0, 0, 0, 0)) - } - } - }; - - let canvas_path = Path2d::new().unwrap(); - canvas_path.add_path_with_transformation( - &draw_commands_to_path2d(commands, is_closed), - bounds_viewbox_matrix.unchecked_ref(), - ); - match path { - DrawPath::Fill { .. } => { + DrawPath::Fill { + commands, style, .. + } => { + let canvas_path = Path2d::new().unwrap(); + canvas_path.add_path_with_transformation( + &draw_commands_to_path2d(commands, false), + bounds_viewbox_matrix.unchecked_ref(), + ); + + let fill_style = match style { + FillStyle::Color(color) => CanvasFillStyle::Color(color.into()), + FillStyle::LinearGradient(gradient) => CanvasFillStyle::Gradient( + create_linear_gradient(context, gradient, true).unwrap(), + ), + FillStyle::RadialGradient(gradient) => CanvasFillStyle::Gradient( + create_radial_gradient(context, gradient, 0.0, true).unwrap(), + ), + FillStyle::FocalGradient { + gradient, + focal_point, + } => CanvasFillStyle::Gradient( + create_radial_gradient(context, gradient, focal_point.to_f64(), true) + .unwrap(), + ), + FillStyle::Bitmap { + id, + matrix, + is_smoothed, + is_repeating, + } => { + let bitmap = if let Ok(bitmap) = create_bitmap_pattern( + *id, + *matrix, + *is_smoothed, + *is_repeating, + bitmap_source, + bitmaps, + context, + ) { + bitmap + } else { + continue; + }; + CanvasFillStyle::Bitmap(bitmap) + } + }; + canvas_data.0.push(CanvasDrawCommand::Fill { path: canvas_path, fill_style, }); } - DrawPath::Stroke { style, .. } => { + DrawPath::Stroke { + commands, + style, + is_closed, + } => { + let canvas_path = Path2d::new().unwrap(); + canvas_path.add_path_with_transformation( + &draw_commands_to_path2d(commands, *is_closed), + bounds_viewbox_matrix.unchecked_ref(), + ); + + let stroke_style = match style.fill_style() { + FillStyle::Color(color) => CanvasStrokeStyle::Color(color.into()), + FillStyle::LinearGradient(gradient) => { + CanvasStrokeStyle::Gradient(gradient.clone(), None) + } + FillStyle::RadialGradient(gradient) => { + CanvasStrokeStyle::Gradient(gradient.clone(), Some(0.0)) + } + FillStyle::FocalGradient { + gradient, + focal_point, + } => CanvasStrokeStyle::Gradient(gradient.clone(), Some(focal_point.to_f64())), + FillStyle::Bitmap { + id, + matrix, + is_smoothed, + is_repeating, + } => { + let bitmap = if let Ok(bitmap) = create_bitmap_pattern( + *id, + *matrix, + *is_smoothed, + *is_repeating, + bitmap_source, + bitmaps, + context, + ) { + bitmap + } else { + continue; + }; + CanvasStrokeStyle::Bitmap(bitmap) + } + }; + let line_cap = match style.start_cap() { LineCapStyle::Round => "round", LineCapStyle::Square => "square", @@ -792,7 +877,7 @@ fn swf_shape_to_canvas_commands( canvas_data.0.push(CanvasDrawCommand::Stroke { path: canvas_path, line_width: style.width().to_pixels(), - stroke_style: fill_style, + stroke_style, line_cap: line_cap.to_string(), line_join: line_join.to_string(), miter_limit: miter_limit as f64 / 20.0, @@ -813,14 +898,14 @@ fn create_linear_gradient( context: &CanvasRenderingContext2d, gradient: &swf::Gradient, is_fill: bool, -) -> Result { +) -> Result { // Canvas linear gradients are configured via the line endpoints, so we only need // to transform it if the basis is not orthogonal (skew in the transform). let transformed = if is_fill { let dot = gradient.matrix.a * gradient.matrix.c + gradient.matrix.b * gradient.matrix.d; dot.to_f32().abs() > GRADIENT_TRANSFORM_THRESHOLD } else { - // TODO: Gradient transforms don't work correctly with strokes. + // Complex gradient transforms can't apply to strokes; fall back to simple transforms. false }; let create_fn = |matrix: swf::Matrix, gradient_scale: f64| { @@ -844,7 +929,7 @@ fn create_radial_gradient( gradient: &swf::Gradient, focal_point: f64, is_fill: bool, -) -> Result { +) -> Result { // Canvas radial gradients can not be elliptical or skewed, so transform if there // is a non-uniform scale or skew. // A scale rotation matrix is always of the form: @@ -854,7 +939,7 @@ fn create_radial_gradient( (gradient.matrix.a - gradient.matrix.d).to_f32().abs() > GRADIENT_TRANSFORM_THRESHOLD || (gradient.matrix.b + gradient.matrix.c).to_f32().abs() > GRADIENT_TRANSFORM_THRESHOLD } else { - // TODO: Gradient transforms don't work correctly with strokes. + // Complex gradient transforms can't apply to strokes; fall back to simple transforms. false }; let create_fn = |matrix: swf::Matrix, gradient_scale: f64| { @@ -893,7 +978,7 @@ fn swf_to_canvas_gradient( swf_gradient: &swf::Gradient, transformed: bool, mut create_gradient_fn: impl FnMut(swf::Matrix, f64) -> Result, -) -> Result { +) -> Result { let matrix = if transformed { // When we are rendering a complex gradient, the gradient transform is handled later by // transforming the path before rendering; so use the indentity matrix here. @@ -983,25 +1068,67 @@ fn swf_to_canvas_gradient( } } - if transformed { - // When we render this gradient, we will push the gradient's transform to the canvas, - // and then transform the path itself by the inverse. - let matrix = swf_gradient.matrix.to_dom_matrix(); - let inverse_gradient_matrix = matrix.inverse(); - Ok(CanvasFillStyle::TransformedGradient(TransformedGradient { - gradient: canvas_gradient, - gradient_matrix: [ - matrix.a(), - matrix.b(), - matrix.c(), - matrix.d(), - matrix.e(), - matrix.f(), - ], - inverse_gradient_matrix, - })) + Ok(Gradient { + gradient: canvas_gradient, + transform: transformed.then(|| { + // When we render this gradient, we will push the gradient's transform to the canvas, + // and then transform the path itself by the inverse. + let matrix = swf_gradient.matrix.to_dom_matrix(); + let inverse_matrix = matrix.inverse(); + GradientTransform { + matrix: [ + matrix.a(), + matrix.b(), + matrix.c(), + matrix.d(), + matrix.e(), + matrix.f(), + ], + inverse_matrix, + } + }), + }) +} + +/// Converts an SWF bitmap fill to a canvas pattern. +fn create_bitmap_pattern( + id: swf::CharacterId, + matrix: swf::Matrix, + is_smoothed: bool, + is_repeating: bool, + bitmap_source: &dyn BitmapSource, + bitmaps: &FnvHashMap, + context: &CanvasRenderingContext2d, +) -> Result { + if let Some(bitmap) = bitmap_source + .bitmap(id) + .and_then(|bitmap| bitmaps.get(&bitmap.handle)) + { + let repeat = if !is_repeating { + // NOTE: The WebGL backend does clamping in this case, just like + // Flash Player, but CanvasPattern has no such option... + "no-repeat" + } else { + "repeat" + }; + + let pattern = match context.create_pattern_with_html_canvas_element(&bitmap.canvas, repeat) + { + Ok(Some(pattern)) => pattern, + _ => { + log::warn!("Unable to create bitmap pattern for bitmap ID {}", id); + return Err("Unable to create bitmap pattern".into()); + } + }; + pattern.set_transform(matrix.to_dom_matrix().unchecked_ref()); + Ok(CanvasBitmap { + pattern, + matrix: matrix.into(), + smoothed: is_smoothed, + }) } else { - Ok(CanvasFillStyle::Gradient(canvas_gradient)) + log::warn!("Couldn't fill shape with unknown bitmap {}", id); + Err("Unable to create bitmap pattern".into()) } }