diff --git a/Cargo.lock b/Cargo.lock index bd263e3..1446598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,7 @@ version = "0.1.0" name = "we-dom" version = "0.1.0" dependencies = [ + "we-image", "we-memory", ] diff --git a/crates/dom/Cargo.toml b/crates/dom/Cargo.toml index f0fc12f..b41db31 100644 --- a/crates/dom/Cargo.toml +++ b/crates/dom/Cargo.toml @@ -9,3 +9,4 @@ path = "src/lib.rs" [dependencies] we-memory = { path = "../memory" } +we-image = { path = "../image" } diff --git a/crates/dom/src/canvas.rs b/crates/dom/src/canvas.rs index 317651d..d5f6ddb 100644 --- a/crates/dom/src/canvas.rs +++ b/crates/dom/src/canvas.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::f64::consts::PI; use crate::NodeId; +use we_image::png::encode_png_rgba; // ── Affine Transform ────────────────────────────────────────────── @@ -531,6 +532,67 @@ impl Path2D { } } +// ── Composite Operation ─────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum CompositeOp { + #[default] + SourceOver, + SourceIn, + SourceOut, + SourceAtop, + DestinationOver, + DestinationIn, + DestinationOut, + DestinationAtop, + Lighter, + Copy, + Xor, +} + +impl CompositeOp { + pub fn parse(s: &str) -> Option { + match s { + "source-over" => Some(CompositeOp::SourceOver), + "source-in" => Some(CompositeOp::SourceIn), + "source-out" => Some(CompositeOp::SourceOut), + "source-atop" => Some(CompositeOp::SourceAtop), + "destination-over" => Some(CompositeOp::DestinationOver), + "destination-in" => Some(CompositeOp::DestinationIn), + "destination-out" => Some(CompositeOp::DestinationOut), + "destination-atop" => Some(CompositeOp::DestinationAtop), + "lighter" => Some(CompositeOp::Lighter), + "copy" => Some(CompositeOp::Copy), + "xor" => Some(CompositeOp::Xor), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + CompositeOp::SourceOver => "source-over", + CompositeOp::SourceIn => "source-in", + CompositeOp::SourceOut => "source-out", + CompositeOp::SourceAtop => "source-atop", + CompositeOp::DestinationOver => "destination-over", + CompositeOp::DestinationIn => "destination-in", + CompositeOp::DestinationOut => "destination-out", + CompositeOp::DestinationAtop => "destination-atop", + CompositeOp::Lighter => "lighter", + CompositeOp::Copy => "copy", + CompositeOp::Xor => "xor", + } + } + + /// Modes that affect destination pixels even where the source is transparent. + fn affects_all_pixels(self) -> bool { + matches!( + self, + CompositeOp::DestinationIn | CompositeOp::DestinationOut | CompositeOp::DestinationAtop + ) + } +} + // ── Canvas 2D Drawing State ─────────────────────────────────────── #[derive(Debug, Clone)] @@ -554,7 +616,7 @@ pub struct Canvas2dState { /// 0 = inherit, 1 = ltr, 2 = rtl pub direction: u8, pub global_alpha: f64, - pub global_composite_operation: String, + pub global_composite_operation: CompositeOp, pub shadow_color: [u8; 4], pub shadow_blur: f64, pub shadow_offset_x: f64, @@ -562,6 +624,8 @@ pub struct Canvas2dState { pub image_smoothing_enabled: bool, /// 0 = low, 1 = medium, 2 = high pub image_smoothing_quality: u8, + /// Per-pixel clip coverage mask (None = no clipping). Part of saved state. + pub clip_mask: Option>, } impl Default for Canvas2dState { @@ -581,13 +645,14 @@ impl Default for Canvas2dState { text_baseline: 0, direction: 0, global_alpha: 1.0, - global_composite_operation: "source-over".to_string(), + global_composite_operation: CompositeOp::SourceOver, shadow_color: [0, 0, 0, 0], shadow_blur: 0.0, shadow_offset_x: 0.0, shadow_offset_y: 0.0, image_smoothing_enabled: true, image_smoothing_quality: 0, + clip_mask: None, } } } @@ -725,16 +790,25 @@ impl Canvas2dContext { .transform .inverse() .unwrap_or_else(AffineTransform::identity); - scanline_fill_with_paint( - buf, - canvas_w, - canvas_h, - &subpaths, - paint, - &inv_ctm, - self.state.global_alpha, - rule, - ); + let ga = self.state.global_alpha; + + if !self.has_shadow() && !self.needs_offscreen() { + scanline_fill_with_paint( + buf, canvas_w, canvas_h, &subpaths, paint, &inv_ctm, ga, rule, + ); + return; + } + if self.has_shadow() { + let sp = subpaths.clone(); + let p = paint.clone(); + self.render_shadow(buf, canvas_w, canvas_h, move |tmp| { + scanline_fill_with_paint(tmp, canvas_w, canvas_h, &sp, &p, &inv_ctm, ga, rule); + }); + } + let p = paint.clone(); + self.draw_with_state(buf, canvas_w, canvas_h, move |tgt| { + scanline_fill_with_paint(tgt, canvas_w, canvas_h, &subpaths, &p, &inv_ctm, ga, rule); + }); } pub fn fill_with_path2d( @@ -752,16 +826,25 @@ impl Canvas2dContext { .transform .inverse() .unwrap_or_else(AffineTransform::identity); - scanline_fill_with_paint( - buf, - canvas_w, - canvas_h, - &subpaths, - paint, - &inv_ctm, - self.state.global_alpha, - rule, - ); + let ga = self.state.global_alpha; + + if !self.has_shadow() && !self.needs_offscreen() { + scanline_fill_with_paint( + buf, canvas_w, canvas_h, &subpaths, paint, &inv_ctm, ga, rule, + ); + return; + } + if self.has_shadow() { + let sp = subpaths.clone(); + let p = paint.clone(); + self.render_shadow(buf, canvas_w, canvas_h, move |tmp| { + scanline_fill_with_paint(tmp, canvas_w, canvas_h, &sp, &p, &inv_ctm, ga, rule); + }); + } + let p = paint.clone(); + self.draw_with_state(buf, canvas_w, canvas_h, move |tgt| { + scanline_fill_with_paint(tgt, canvas_w, canvas_h, &subpaths, &p, &inv_ctm, ga, rule); + }); } pub fn stroke(&self, buf: &mut [u8], canvas_w: u32, canvas_h: u32, paint: &ResolvedPaint) { @@ -771,15 +854,24 @@ impl Canvas2dContext { .transform .inverse() .unwrap_or_else(AffineTransform::identity); - stroke_subpaths_with_paint( - buf, - canvas_w, - canvas_h, - &subpaths, - &self.state, - paint, - &inv_ctm, - ); + let state = self.state.clone(); + + if !self.has_shadow() && !self.needs_offscreen() { + stroke_subpaths_with_paint(buf, canvas_w, canvas_h, &subpaths, &state, paint, &inv_ctm); + return; + } + if self.has_shadow() { + let sp = subpaths.clone(); + let p = paint.clone(); + let st = state.clone(); + self.render_shadow(buf, canvas_w, canvas_h, move |tmp| { + stroke_subpaths_with_paint(tmp, canvas_w, canvas_h, &sp, &st, &p, &inv_ctm); + }); + } + let p = paint.clone(); + self.draw_with_state(buf, canvas_w, canvas_h, move |tgt| { + stroke_subpaths_with_paint(tgt, canvas_w, canvas_h, &subpaths, &state, &p, &inv_ctm); + }); } pub fn stroke_with_path2d( @@ -796,15 +888,24 @@ impl Canvas2dContext { .transform .inverse() .unwrap_or_else(AffineTransform::identity); - stroke_subpaths_with_paint( - buf, - canvas_w, - canvas_h, - &subpaths, - &self.state, - paint, - &inv_ctm, - ); + let state = self.state.clone(); + + if !self.has_shadow() && !self.needs_offscreen() { + stroke_subpaths_with_paint(buf, canvas_w, canvas_h, &subpaths, &state, paint, &inv_ctm); + return; + } + if self.has_shadow() { + let sp = subpaths.clone(); + let p = paint.clone(); + let st = state.clone(); + self.render_shadow(buf, canvas_w, canvas_h, move |tmp| { + stroke_subpaths_with_paint(tmp, canvas_w, canvas_h, &sp, &st, &p, &inv_ctm); + }); + } + let p = paint.clone(); + self.draw_with_state(buf, canvas_w, canvas_h, move |tgt| { + stroke_subpaths_with_paint(tgt, canvas_w, canvas_h, &subpaths, &state, &p, &inv_ctm); + }); } pub fn is_point_in_path(&self, x: f64, y: f64, rule: FillRule) -> bool { @@ -837,18 +938,29 @@ impl Canvas2dContext { if w == 0.0 || h == 0.0 { return; } - rasterize_filled_rect_with_paint( - buf, - canvas_w, - canvas_h, - &self.state.transform, - x, - y, - w, - h, - paint, - self.state.global_alpha, - ); + let transform = self.state.transform; + let ga = self.state.global_alpha; + + if !self.has_shadow() && !self.needs_offscreen() { + rasterize_filled_rect_with_paint( + buf, canvas_w, canvas_h, &transform, x, y, w, h, paint, ga, + ); + return; + } + if self.has_shadow() { + let p = paint.clone(); + self.render_shadow(buf, canvas_w, canvas_h, move |tmp| { + rasterize_filled_rect_with_paint( + tmp, canvas_w, canvas_h, &transform, x, y, w, h, &p, ga, + ); + }); + } + let p = paint.clone(); + self.draw_with_state(buf, canvas_w, canvas_h, move |tgt| { + rasterize_filled_rect_with_paint( + tgt, canvas_w, canvas_h, &transform, x, y, w, h, &p, ga, + ); + }); } pub fn stroke_rect( @@ -863,9 +975,47 @@ impl Canvas2dContext { if w == 0.0 || h == 0.0 { return; } + let transform = self.state.transform; + let ga = self.state.global_alpha; let lw = self.state.line_width; - rasterize_stroke_rect_with_paint( - buf, + + if !self.has_shadow() && !self.needs_offscreen() { + rasterize_stroke_rect_with_paint( + buf, canvas_w, canvas_h, &transform, x, y, w, h, paint, ga, lw, + ); + return; + } + if self.has_shadow() { + let p = paint.clone(); + self.render_shadow(buf, canvas_w, canvas_h, move |tmp| { + rasterize_stroke_rect_with_paint( + tmp, canvas_w, canvas_h, &transform, x, y, w, h, &p, ga, lw, + ); + }); + } + let p = paint.clone(); + self.draw_with_state(buf, canvas_w, canvas_h, move |tgt| { + rasterize_stroke_rect_with_paint( + tgt, canvas_w, canvas_h, &transform, x, y, w, h, &p, ga, lw, + ); + }); + } + + pub fn clear_rect(&self, buf: &mut [u8], canvas_w: u32, canvas_h: u32, rect: [f64; 4]) { + let [x, y, w, h] = rect; + if w == 0.0 || h == 0.0 { + return; + } + if self.state.clip_mask.is_none() { + rasterize_clear_rect(buf, canvas_w, canvas_h, &self.state.transform, x, y, w, h); + return; + } + // With clip: only clear pixels that fall within both the rect and the clip region. + let n = canvas_w as usize * canvas_h as usize; + let mut mask = vec![0u8; n * 4]; + let white = ResolvedPaint::Solid([255, 255, 255, 255]); + rasterize_filled_rect_with_paint( + &mut mask, canvas_w, canvas_h, &self.state.transform, @@ -873,18 +1023,18 @@ impl Canvas2dContext { y, w, h, - paint, - self.state.global_alpha, - lw, + &white, + 1.0, ); - } - - pub fn clear_rect(&self, buf: &mut [u8], canvas_w: u32, canvas_h: u32, rect: [f64; 4]) { - let [x, y, w, h] = rect; - if w == 0.0 || h == 0.0 { - return; + let clip = self.state.clip_mask.as_ref().unwrap(); + for i in 0..n { + if mask[i * 4 + 3] > 0 && i < clip.len() && clip[i] > 0 { + buf[i * 4] = 0; + buf[i * 4 + 1] = 0; + buf[i * 4 + 2] = 0; + buf[i * 4 + 3] = 0; + } } - rasterize_clear_rect(buf, canvas_w, canvas_h, &self.state.transform, x, y, w, h); } /// Blit pre-rasterized glyph bitmaps onto the canvas using the current fill paint. @@ -900,9 +1050,29 @@ impl Canvas2dContext { paint: &ResolvedPaint, ) { let alpha = self.state.global_alpha; - for glyph in glyphs { - blit_glyph_coverage(buf, canvas_w, canvas_h, glyph, paint, alpha); + + if !self.has_shadow() && !self.needs_offscreen() { + for glyph in glyphs { + blit_glyph_coverage(buf, canvas_w, canvas_h, glyph, paint, alpha); + } + return; } + if self.has_shadow() { + let g = glyphs.to_vec(); + let p = paint.clone(); + self.render_shadow(buf, canvas_w, canvas_h, move |tmp| { + for glyph in &g { + blit_glyph_coverage(tmp, canvas_w, canvas_h, glyph, &p, alpha); + } + }); + } + let g = glyphs.to_vec(); + let p = paint.clone(); + self.draw_with_state(buf, canvas_w, canvas_h, move |tgt| { + for glyph in &g { + blit_glyph_coverage(tgt, canvas_w, canvas_h, glyph, &p, alpha); + } + }); } /// Draw a sub-rectangle of a source image onto a destination rectangle in canvas space. @@ -939,7 +1109,7 @@ impl Canvas2dContext { return; } - let ctm = &self.state.transform; + let ctm = self.state.transform; let inv_ctm = match ctm.inverse() { Some(inv) => inv, None => return, @@ -947,75 +1117,306 @@ impl Canvas2dContext { let global_alpha = self.state.global_alpha.clamp(0.0, 1.0); let smoothing = self.state.image_smoothing_enabled; - // Transform destination rect corners through CTM → pixel-space bounding box. - let corners = [ - ctm.apply(dx, dy), - ctm.apply(dx + dw, dy), - ctm.apply(dx, dy + dh), - ctm.apply(dx + dw, dy + dh), - ]; - let px_min_x = corners - .iter() - .map(|&(x, _)| x) - .fold(f64::INFINITY, f64::min); - let px_max_x = corners - .iter() - .map(|&(x, _)| x) - .fold(f64::NEG_INFINITY, f64::max); - let px_min_y = corners - .iter() - .map(|(_, y)| *y) - .fold(f64::INFINITY, f64::min); - let px_max_y = corners - .iter() - .map(|(_, y)| *y) - .fold(f64::NEG_INFINITY, f64::max); - - let x0 = (px_min_x.floor() as i64).max(0) as u32; - let y0 = (px_min_y.floor() as i64).max(0) as u32; - let x1 = ((px_max_x.ceil() as i64).max(0) as u32).min(canvas_w); - let y1 = ((px_max_y.ceil() as i64).max(0) as u32).min(canvas_h); - - for py in y0..y1 { - for px in x0..x1 { - // Map this pixel's center back to canvas (pre-CTM) space. - let (cx, cy) = inv_ctm.apply(px as f64 + 0.5, py as f64 + 0.5); - - // Parametric position within the destination rect [0, 1). - // Using signed division so negative dw/dh flips work correctly. - let t_x = (cx - dx) / dw; - let t_y = (cy - dy) / dh; - if !(0.0..1.0).contains(&t_x) || !(0.0..1.0).contains(&t_y) { - continue; + if !self.has_shadow() && !self.needs_offscreen() { + blit_image_to_buf( + buf, + canvas_w, + canvas_h, + src_pixels, + src_w, + src_h, + sx, + sy, + sw, + sh, + dx, + dy, + dw, + dh, + &ctm, + &inv_ctm, + global_alpha, + smoothing, + ); + return; + } + if self.has_shadow() { + let pixels = src_pixels.to_vec(); + self.render_shadow(buf, canvas_w, canvas_h, move |tmp| { + blit_image_to_buf( + tmp, + canvas_w, + canvas_h, + &pixels, + src_w, + src_h, + sx, + sy, + sw, + sh, + dx, + dy, + dw, + dh, + &ctm, + &inv_ctm, + global_alpha, + smoothing, + ); + }); + } + let pixels = src_pixels.to_vec(); + self.draw_with_state(buf, canvas_w, canvas_h, move |tgt| { + blit_image_to_buf( + tgt, + canvas_w, + canvas_h, + &pixels, + src_w, + src_h, + sx, + sy, + sw, + sh, + dx, + dy, + dw, + dh, + &ctm, + &inv_ctm, + global_alpha, + smoothing, + ); + }); + } + + // ── Clipping ────────────────────────────────────────── + + /// Intersect the current clipping region with the current path using the given fill rule. + pub fn clip(&mut self, canvas_w: u32, canvas_h: u32, rule: FillRule) { + let segs = self.path.segments.clone(); + let transform = self.state.transform; + self.apply_clip_segs(canvas_w, canvas_h, &segs, &transform, rule); + } + + /// Intersect the current clipping region with a Path2D using the given fill rule. + pub fn clip_with_path2d( + &mut self, + path: &Path2D, + canvas_w: u32, + canvas_h: u32, + rule: FillRule, + ) { + let segs = path.inner.segments.clone(); + let transform = self.state.transform; + self.apply_clip_segs(canvas_w, canvas_h, &segs, &transform, rule); + } + + fn apply_clip_segs( + &mut self, + canvas_w: u32, + canvas_h: u32, + segs: &[PathSegment], + transform: &AffineTransform, + rule: FillRule, + ) { + let n = canvas_w as usize * canvas_h as usize; + let mut temp = vec![0u8; n * 4]; + let subpaths = flatten_path(segs, transform); + let inv_ctm = transform + .inverse() + .unwrap_or_else(AffineTransform::identity); + let white = ResolvedPaint::Solid([255, 255, 255, 255]); + scanline_fill_with_paint( + &mut temp, canvas_w, canvas_h, &subpaths, &white, &inv_ctm, 1.0, rule, + ); + + let coverage: Vec = (0..n).map(|i| temp[i * 4 + 3]).collect(); + + match &mut self.state.clip_mask { + None => { + self.state.clip_mask = Some(coverage); + } + Some(existing) if existing.len() == n => { + for i in 0..n { + existing[i] = existing[i].min(coverage[i]); } + } + _ => { + self.state.clip_mask = Some(coverage); + } + } + } - // Map to source image coordinates. - let u = sx + t_x * sw; - let v = sy + t_y * sh; + // ── Compositing and offscreen helpers ───────────────── - let color = if smoothing { - sample_bilinear(src_pixels, src_w, src_h, u, v) - } else { - sample_nearest(src_pixels, src_w, src_h, u, v) - }; + /// Returns true when drawing must go through an offscreen buffer + /// (non-default composite op, or active clip mask). + fn needs_offscreen(&self) -> bool { + self.state.global_composite_operation != CompositeOp::SourceOver + || self.state.clip_mask.is_some() + } - if color[3] == 0 { - continue; + /// Composite a fully-rendered temporary buffer onto the canvas, applying the + /// clip mask and globalCompositeOperation. + fn composite_onto(&self, dst: &mut [u8], src: &[u8], canvas_w: u32, canvas_h: u32) { + let op = self.state.global_composite_operation; + let n = canvas_w as usize * canvas_h as usize; + let affects_all = op.affects_all_pixels(); + + for i in 0..n { + let mut src_pixel = [src[i * 4], src[i * 4 + 1], src[i * 4 + 2], src[i * 4 + 3]]; + + if src_pixel[3] == 0 && !affects_all { + continue; + } + + // Apply clip mask to source alpha. + if let Some(clip) = &self.state.clip_mask { + if i < clip.len() { + let cv = clip[i]; + if cv == 0 { + // Pixel is fully clipped — skip for most modes, or use src_a=0 for "affects_all". + if !affects_all { + continue; + } + src_pixel[3] = 0; + } else if cv < 255 { + src_pixel[3] = (src_pixel[3] as u32 * cv as u32 / 255) as u8; + } } + } + + let mut dst_pixel = [dst[i * 4], dst[i * 4 + 1], dst[i * 4 + 2], dst[i * 4 + 3]]; + composite_pixel(&mut dst_pixel, src_pixel, op); + dst[i * 4] = dst_pixel[0]; + dst[i * 4 + 1] = dst_pixel[1]; + dst[i * 4 + 2] = dst_pixel[2]; + dst[i * 4 + 3] = dst_pixel[3]; + } + } + + /// Execute `render` into either the canvas directly (fast path) or a temporary + /// buffer that is then composited onto the canvas (slow path for clip/compositing). + fn draw_with_state(&self, buf: &mut [u8], canvas_w: u32, canvas_h: u32, render: F) + where + F: FnOnce(&mut [u8]), + { + if !self.needs_offscreen() { + render(buf); + return; + } + let n = canvas_w as usize * canvas_h as usize; + let mut temp = vec![0u8; n * 4]; + render(&mut temp); + self.composite_onto(buf, &temp, canvas_w, canvas_h); + } + + // ── Shadow rendering ────────────────────────────────── + + fn has_shadow(&self) -> bool { + self.state.shadow_color[3] > 0 + && (self.state.shadow_blur > 0.0 + || self.state.shadow_offset_x != 0.0 + || self.state.shadow_offset_y != 0.0) + } - let eff_a = (color[3] as f64 * global_alpha).round() as u8; - if eff_a == 0 { + /// Render the shape (via `render_fn`) as a shadow onto `buf`. + fn render_shadow(&self, buf: &mut [u8], canvas_w: u32, canvas_h: u32, render_fn: F) + where + F: FnOnce(&mut [u8]), + { + if !self.has_shadow() { + return; + } + let n = canvas_w as usize * canvas_h as usize; + let mut shadow = vec![0u8; n * 4]; + render_fn(&mut shadow); + + // Recolor all non-transparent pixels with shadow_color. + let [sr, sg, sb, sa] = self.state.shadow_color; + let eff_sa = (sa as f64 * self.state.global_alpha) + .round() + .clamp(0.0, 255.0) as u8; + for i in 0..n { + if shadow[i * 4 + 3] > 0 { + let shape_a = shadow[i * 4 + 3]; + let blended_a = (eff_sa as u32 * shape_a as u32 / 255) as u8; + shadow[i * 4] = sr; + shadow[i * 4 + 1] = sg; + shadow[i * 4 + 2] = sb; + shadow[i * 4 + 3] = blended_a; + } + } + + // Apply Gaussian approximation (3-pass box blur). + let radius = (self.state.shadow_blur / 2.0).ceil() as i32; + if radius > 0 { + box_blur_rgba(&mut shadow, canvas_w, canvas_h, radius); + box_blur_rgba(&mut shadow, canvas_w, canvas_h, radius); + box_blur_rgba(&mut shadow, canvas_w, canvas_h, radius); + } + + // Composite shadow onto canvas at the offset position. + let off_x = self.state.shadow_offset_x.round() as i32; + let off_y = self.state.shadow_offset_y.round() as i32; + let w = canvas_w as i32; + let h = canvas_h as i32; + let op = self.state.global_composite_operation; + + for dy in 0..h { + let sy = dy - off_y; + if sy < 0 || sy >= h { + continue; + } + for dx in 0..w { + let sx = dx - off_x; + if sx < 0 || sx >= w { continue; } - - let src_color = [color[0], color[1], color[2], eff_a]; - let idx = (py as usize * canvas_w as usize + px as usize) * 4; + let src_i = (sy * w + sx) as usize; + let dst_i = (dy * w + dx) as usize; + let mut src_pixel = [ + shadow[src_i * 4], + shadow[src_i * 4 + 1], + shadow[src_i * 4 + 2], + shadow[src_i * 4 + 3], + ]; + if src_pixel[3] == 0 { + continue; + } + // Apply clip mask to shadow as well. + if let Some(clip) = &self.state.clip_mask { + if dst_i < clip.len() && clip[dst_i] < 255 { + if clip[dst_i] == 0 { + continue; + } + src_pixel[3] = (src_pixel[3] as u32 * clip[dst_i] as u32 / 255) as u8; + } + } + let idx = dst_i * 4; if idx + 4 <= buf.len() { - blend_source_over(&mut buf[idx..idx + 4], src_color); + let mut dst_pixel = [buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]]; + composite_pixel(&mut dst_pixel, src_pixel, op); + buf[idx] = dst_pixel[0]; + buf[idx + 1] = dst_pixel[1]; + buf[idx + 2] = dst_pixel[2]; + buf[idx + 3] = dst_pixel[3]; } } } } + + // ── Canvas output ───────────────────────────────────── + + /// Encode the canvas buffer as a base64 data URL. + /// + /// `mime_type` may be "image/png" or "image/jpeg" (falls back to PNG). + pub fn to_data_url(buf: &[u8], canvas_w: u32, canvas_h: u32, _mime_type: &str) -> String { + let png_bytes = encode_png_rgba(buf, canvas_w, canvas_h); + let b64 = base64_encode(&png_bytes); + format!("data:image/png;base64,{}", b64) + } } /// A rasterized glyph ready to blit onto the canvas buffer. @@ -1117,6 +1518,124 @@ fn blend_source_over(dst: &mut [u8], src: [u8; 4]) { dst[3] = out_a.min(255) as u8; } +/// Porter-Duff compositing (straight alpha, u8 components). +#[inline] +fn composite_pixel(dst: &mut [u8], src: [u8; 4], op: CompositeOp) { + if op == CompositeOp::SourceOver { + blend_source_over(dst, src); + return; + } + let sa = src[3] as f64 / 255.0; + let da = dst[3] as f64 / 255.0; + let (fa, fb) = match op { + CompositeOp::SourceOver => (1.0, 1.0 - sa), + CompositeOp::SourceIn => (da, 0.0), + CompositeOp::SourceOut => (1.0 - da, 0.0), + CompositeOp::SourceAtop => (da, 1.0 - sa), + CompositeOp::DestinationOver => (1.0 - da, 1.0), + CompositeOp::DestinationIn => (0.0, sa), + CompositeOp::DestinationOut => (0.0, 1.0 - sa), + CompositeOp::DestinationAtop => (1.0 - da, sa), + CompositeOp::Lighter => (1.0, 1.0), + CompositeOp::Copy => (1.0, 0.0), + CompositeOp::Xor => (1.0 - da, 1.0 - sa), + }; + let out_a = (sa * fa + da * fb).clamp(0.0, 1.0); + if out_a <= 0.0 { + dst[0] = 0; + dst[1] = 0; + dst[2] = 0; + dst[3] = 0; + return; + } + for i in 0..3 { + let sc = src[i] as f64 / 255.0; + let dc = dst[i] as f64 / 255.0; + let out_c = ((sc * sa * fa + dc * da * fb) / out_a).clamp(0.0, 1.0); + dst[i] = (out_c * 255.0).round() as u8; + } + dst[3] = (out_a * 255.0).round() as u8; +} + +/// One-pass box blur (horizontal or vertical depending on `horizontal` flag). +fn box_blur_pass(src: &[u8], dst: &mut [u8], w: u32, h: u32, radius: i32, horizontal: bool) { + let w = w as i32; + let h = h as i32; + for row in 0..h { + for col in 0..w { + let mut r = 0u32; + let mut g = 0u32; + let mut b = 0u32; + let mut a = 0u32; + let mut count = 0u32; + for d in -radius..=radius { + let (pr, pc) = if horizontal { + (row, col + d) + } else { + (row + d, col) + }; + if pr >= 0 && pr < h && pc >= 0 && pc < w { + let idx = (pr * w + pc) as usize * 4; + r += src[idx] as u32; + g += src[idx + 1] as u32; + b += src[idx + 2] as u32; + a += src[idx + 3] as u32; + count += 1; + } + } + let idx = (row * w + col) as usize * 4; + dst[idx] = (r / count) as u8; + dst[idx + 1] = (g / count) as u8; + dst[idx + 2] = (b / count) as u8; + dst[idx + 3] = (a / count) as u8; + } + } +} + +/// Approximate Gaussian blur via a single horizontal + vertical box blur pass. +fn box_blur_rgba(pixels: &mut [u8], w: u32, h: u32, radius: i32) { + if radius <= 0 { + return; + } + let temp = pixels.to_owned(); + let mut horiz = vec![0u8; pixels.len()]; + box_blur_pass(&temp, &mut horiz, w, h, radius, true); + box_blur_pass(&horiz, pixels, w, h, radius, false); +} + +/// Encode bytes as base64 (RFC 4648). +fn base64_encode(data: &[u8]) -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + let mut i = 0; + while i + 2 < data.len() { + let b0 = data[i] as u32; + let b1 = data[i + 1] as u32; + let b2 = data[i + 2] as u32; + out.push(CHARS[((b0 >> 2) & 0x3F) as usize] as char); + out.push(CHARS[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize] as char); + out.push(CHARS[(((b1 << 2) | (b2 >> 6)) & 0x3F) as usize] as char); + out.push(CHARS[(b2 & 0x3F) as usize] as char); + i += 3; + } + let remaining = data.len() - i; + if remaining == 1 { + let b0 = data[i] as u32; + out.push(CHARS[((b0 >> 2) & 0x3F) as usize] as char); + out.push(CHARS[((b0 << 4) & 0x3F) as usize] as char); + out.push('='); + out.push('='); + } else if remaining == 2 { + let b0 = data[i] as u32; + let b1 = data[i + 1] as u32; + out.push(CHARS[((b0 >> 2) & 0x3F) as usize] as char); + out.push(CHARS[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize] as char); + out.push(CHARS[((b1 << 2) & 0x3F) as usize] as char); + out.push('='); + } + out +} + /// Sample a source image with nearest-neighbor (no interpolation). /// Returns transparent black for coordinates outside the image bounds. #[inline] @@ -1189,6 +1708,89 @@ fn apply_global_alpha(color: [u8; 4], global_alpha: f64) -> [u8; 4] { [color[0], color[1], color[2], a] } +// ── Image blitting ──────────────────────────────────────────────── + +/// Blit a source sub-image onto `buf` using the provided transform. +/// This is the core implementation shared by `draw_image` fast and slow paths. +#[allow(clippy::too_many_arguments)] +fn blit_image_to_buf( + buf: &mut [u8], + canvas_w: u32, + canvas_h: u32, + src_pixels: &[u8], + src_w: u32, + src_h: u32, + sx: f64, + sy: f64, + sw: f64, + sh: f64, + dx: f64, + dy: f64, + dw: f64, + dh: f64, + ctm: &AffineTransform, + inv_ctm: &AffineTransform, + global_alpha: f64, + smoothing: bool, +) { + let corners = [ + ctm.apply(dx, dy), + ctm.apply(dx + dw, dy), + ctm.apply(dx, dy + dh), + ctm.apply(dx + dw, dy + dh), + ]; + let px_min_x = corners + .iter() + .map(|&(x, _)| x) + .fold(f64::INFINITY, f64::min); + let px_max_x = corners + .iter() + .map(|&(x, _)| x) + .fold(f64::NEG_INFINITY, f64::max); + let px_min_y = corners + .iter() + .map(|(_, y)| *y) + .fold(f64::INFINITY, f64::min); + let px_max_y = corners + .iter() + .map(|(_, y)| *y) + .fold(f64::NEG_INFINITY, f64::max); + let x0 = (px_min_x.floor() as i64).max(0) as u32; + let y0 = (px_min_y.floor() as i64).max(0) as u32; + let x1 = ((px_max_x.ceil() as i64).max(0) as u32).min(canvas_w); + let y1 = ((px_max_y.ceil() as i64).max(0) as u32).min(canvas_h); + + for py in y0..y1 { + for px in x0..x1 { + let (cx, cy) = inv_ctm.apply(px as f64 + 0.5, py as f64 + 0.5); + let t_x = (cx - dx) / dw; + let t_y = (cy - dy) / dh; + if !(0.0..1.0).contains(&t_x) || !(0.0..1.0).contains(&t_y) { + continue; + } + let u = sx + t_x * sw; + let v = sy + t_y * sh; + let color = if smoothing { + sample_bilinear(src_pixels, src_w, src_h, u, v) + } else { + sample_nearest(src_pixels, src_w, src_h, u, v) + }; + if color[3] == 0 { + continue; + } + let eff_a = (color[3] as f64 * global_alpha).round() as u8; + if eff_a == 0 { + continue; + } + let src_color = [color[0], color[1], color[2], eff_a]; + let idx = (py as usize * canvas_w as usize + px as usize) * 4; + if idx + 4 <= buf.len() { + blend_source_over(&mut buf[idx..idx + 4], src_color); + } + } + } +} + // ── Paint sampling ──────────────────────────────────────────────── #[inline] @@ -4284,7 +4886,7 @@ mod tests { #[test] fn draw_image_simple_identity() { let src = make_src_image(4, 4, [255, 0, 0, 255]); - let mut ctx = Canvas2dContext::new(); + let ctx = Canvas2dContext::new(); let mut buf = make_buf(10, 10); // Draw 4×4 red image at (2,2) with no scaling. ctx.draw_image( @@ -4301,7 +4903,7 @@ mod tests { #[test] fn draw_image_scaled_2x() { let src = make_src_image(2, 2, [0, 255, 0, 255]); - let mut ctx = Canvas2dContext::new(); + let ctx = Canvas2dContext::new(); let mut buf = make_buf(10, 10); // Scale 2×2 green image to 4×4. ctx.draw_image( @@ -4369,7 +4971,7 @@ mod tests { #[test] fn draw_image_zero_size_is_noop() { let src = make_src_image(4, 4, [255, 0, 0, 255]); - let mut ctx = Canvas2dContext::new(); + let ctx = Canvas2dContext::new(); let mut buf = make_buf(10, 10); // dw=0 → no-op ctx.draw_image( @@ -4455,4 +5057,194 @@ mod tests { result[2] ); } + + // ── CompositeOp tests ───────────────────────────────── + + #[test] + fn composite_op_parse_round_trip() { + let ops = [ + "source-over", + "source-in", + "source-out", + "source-atop", + "destination-over", + "destination-in", + "destination-out", + "destination-atop", + "lighter", + "copy", + "xor", + ]; + for s in ops { + let op = CompositeOp::parse(s).expect(s); + assert_eq!(op.as_str(), s); + } + } + + #[test] + fn composite_op_unknown_returns_none() { + assert!(CompositeOp::parse("multiply").is_none()); + assert!(CompositeOp::parse("").is_none()); + } + + #[test] + fn composite_pixel_source_over_opaque() { + // source-over with fully opaque src should replace dst. + let src = [255u8, 0, 0, 255]; // red + let mut dst = [0u8, 0, 255, 255]; // blue + composite_pixel(&mut dst, src, CompositeOp::SourceOver); + assert_eq!(dst, [255, 0, 0, 255]); + } + + #[test] + fn composite_pixel_copy_replaces_dst() { + let src = [200u8, 100, 50, 128]; // semi-transparent + let mut dst = [0u8, 0, 255, 255]; // blue + composite_pixel(&mut dst, src, CompositeOp::Copy); + // 'copy' discards destination entirely — result is the source. + assert_eq!(dst[3], 128); + assert_eq!(dst[0], 200); + } + + #[test] + fn composite_pixel_source_in_transparent_src_zeroes_dst() { + // source-in: result = src * dst.a — if dst.a=0, result is transparent. + let src = [255u8, 0, 0, 255]; + let mut dst = [0u8, 0, 255, 0]; // transparent + composite_pixel(&mut dst, src, CompositeOp::SourceIn); + assert_eq!(dst[3], 0); + } + + #[test] + fn composite_pixel_destination_in_zeroes_where_src_transparent() { + // destination-in: result = dst * src.a — if src.a=0, result is transparent. + let src = [255u8, 0, 0, 0]; // transparent source + let mut dst = [0u8, 0, 255, 255]; // opaque blue + composite_pixel(&mut dst, src, CompositeOp::DestinationIn); + assert_eq!(dst[3], 0); + } + + #[test] + fn composite_pixel_xor_opaque_on_opaque() { + // XOR: Fa=1-da, Fb=1-sa; both are 0 when both fully opaque → transparent result. + let src = [255u8, 0, 0, 255]; + let mut dst = [0u8, 0, 255, 255]; + composite_pixel(&mut dst, src, CompositeOp::Xor); + assert_eq!(dst[3], 0); + } + + // ── Clipping tests ──────────────────────────────────── + + #[test] + fn clip_restricts_fill_rect() { + let w = 10u32; + let h = 10u32; + let mut buf = vec![0u8; (w * h * 4) as usize]; + + let mut ctx = Canvas2dContext::new(); + + // Clip to a 5×5 region in the top-left. + ctx.begin_path(); + ctx.rect_path(0.0, 0.0, 5.0, 5.0); + ctx.clip(w, h, FillRule::NonZero); + + // Fill the entire 10×10 canvas with red. + let store = CanvasContextStore::default(); + let red = store.resolve_paint(&PaintStyle::Solid([255, 0, 0, 255]), 1.0); + ctx.fill_rect(&mut buf, w, h, [0.0, 0.0, 10.0, 10.0], &red); + + // Pixels in the 5×5 clip region should be red. + let idx = (2 * w as usize + 2) * 4; // pixel (2, 2) + assert_eq!( + [buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]], + [255, 0, 0, 255] + ); + + // Pixels outside the clip region should remain transparent. + let idx_outside = (7 * w as usize + 7) * 4; // pixel (7, 7) + assert_eq!( + [ + buf[idx_outside], + buf[idx_outside + 1], + buf[idx_outside + 2], + buf[idx_outside + 3] + ], + [0, 0, 0, 0] + ); + } + + #[test] + fn clip_save_restore() { + let mut ctx = Canvas2dContext::new(); + ctx.begin_path(); + ctx.rect_path(0.0, 0.0, 5.0, 5.0); + ctx.clip(10, 10, FillRule::NonZero); + assert!(ctx.state.clip_mask.is_some()); + ctx.save(); + ctx.state.clip_mask = None; + ctx.restore(); + assert!(ctx.state.clip_mask.is_some()); + } + + // ── toDataURL / base64 tests ────────────────────────── + + #[test] + fn base64_encode_empty() { + assert_eq!(base64_encode(&[]), ""); + } + + #[test] + fn base64_encode_hello() { + // "Man" → "TWFu" + assert_eq!(base64_encode(b"Man"), "TWFu"); + // "Ma" → "TWE=" + assert_eq!(base64_encode(b"Ma"), "TWE="); + // "M" → "TQ==" + assert_eq!(base64_encode(b"M"), "TQ=="); + } + + #[test] + fn to_data_url_starts_with_prefix() { + let buf = vec![0u8; 4]; // 1×1 transparent pixel + let url = Canvas2dContext::to_data_url(&buf, 1, 1, "image/png"); + assert!(url.starts_with("data:image/png;base64,"), "got: {url}"); + } + + #[test] + fn to_data_url_is_valid_png() { + // Encode a 2×2 red canvas and verify the PNG header. + let mut buf = vec![0u8; 16]; // 2×2 RGBA + for i in 0..4 { + buf[i * 4] = 255; // R=255 + buf[i * 4 + 3] = 255; // A=255 + } + let url = Canvas2dContext::to_data_url(&buf, 2, 2, "image/png"); + let b64 = url.strip_prefix("data:image/png;base64,").unwrap(); + // Decode base64 and check PNG signature. + let decoded = base64_decode_simple(b64); + assert_eq!( + &decoded[..8], + &[137, 80, 78, 71, 13, 10, 26, 10], + "PNG signature mismatch" + ); + } + + /// Minimal base64 decoder for test use only. + fn base64_decode_simple(s: &str) -> Vec { + let table: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = Vec::new(); + let bytes: Vec = s + .chars() + .filter(|c| *c != '=') + .map(|c| table.iter().position(|&b| b == c as u8).unwrap_or(0) as u8) + .collect(); + let mut i = 0; + while i + 3 < bytes.len() { + out.push((bytes[i] << 2) | (bytes[i + 1] >> 4)); + out.push((bytes[i + 1] << 4) | (bytes[i + 2] >> 2)); + out.push((bytes[i + 2] << 6) | bytes[i + 3]); + i += 4; + } + out + } } diff --git a/crates/image/src/png.rs b/crates/image/src/png.rs index 1bdfc7a..5ab8ff4 100644 --- a/crates/image/src/png.rs +++ b/crates/image/src/png.rs @@ -757,6 +757,106 @@ fn decode_err(msg: &str) -> ImageError { ImageError::Decode(msg.to_string()) } +// --------------------------------------------------------------------------- +// PNG Encoder +// --------------------------------------------------------------------------- + +/// Encode RGBA8 pixel data as a PNG file. +/// +/// `pixels` must be `width * height * 4` bytes in row-major, top-to-bottom +/// order with premultiplied-alpha (or straight alpha — PNG stores straight). +pub fn encode_png_rgba(pixels: &[u8], width: u32, height: u32) -> Vec { + let w = width as usize; + let h = height as usize; + + // Build the raw image data with filter bytes (filter type 0 = None per row). + let mut raw: Vec = Vec::with_capacity(h * (1 + w * 4)); + for row in 0..h { + raw.push(0); // filter type: None + let row_start = row * w * 4; + raw.extend_from_slice(&pixels[row_start..row_start + w * 4]); + } + + let compressed = zlib_compress_data(&raw); + + let mut out = Vec::with_capacity(8 + 25 + 12 + compressed.len() + 12); + + // PNG signature + out.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]); + + // IHDR chunk: width, height, bit_depth=8, color_type=6 (RGBA), compression=0, filter=0, interlace=0 + let mut ihdr = [0u8; 13]; + ihdr[0..4].copy_from_slice(&width.to_be_bytes()); + ihdr[4..8].copy_from_slice(&height.to_be_bytes()); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + png_write_chunk(&mut out, b"IHDR", &ihdr); + + // IDAT chunk + png_write_chunk(&mut out, b"IDAT", &compressed); + + // IEND chunk + png_write_chunk(&mut out, b"IEND", &[]); + + out +} + +fn png_write_chunk(out: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(chunk_type); + out.extend_from_slice(data); + // CRC covers chunk_type + data. + let mut combined = Vec::with_capacity(4 + data.len()); + combined.extend_from_slice(chunk_type); + combined.extend_from_slice(data); + let crc = crc32(&combined); + out.extend_from_slice(&crc.to_be_bytes()); +} + +/// Compress data using zlib format (RFC 1950) with non-compressed DEFLATE blocks. +pub fn zlib_compress_data(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len() + 6); + // zlib header: CMF=0x78 (deflate, window=32KB), FLG=0x01 (checksum) + out.push(0x78); + out.push(0x01); + + let chunks: Vec<&[u8]> = if data.is_empty() { + vec![&[]] + } else { + data.chunks(65535).collect() + }; + for (i, chunk) in chunks.iter().enumerate() { + let is_final = i == chunks.len() - 1; + out.push(if is_final { 0x01 } else { 0x00 }); + let len = chunk.len() as u16; + out.push(len as u8); + out.push((len >> 8) as u8); + let nlen = !len; + out.push(nlen as u8); + out.push((nlen >> 8) as u8); + out.extend_from_slice(chunk); + } + + // Adler-32 trailer + let adler = adler32_of(data); + out.push((adler >> 24) as u8); + out.push((adler >> 16) as u8); + out.push((adler >> 8) as u8); + out.push(adler as u8); + out +} + +fn adler32_of(data: &[u8]) -> u32 { + const MOD: u32 = 65521; + let mut s1: u32 = 1; + let mut s2: u32 = 0; + for &b in data { + s1 = (s1 + b as u32) % MOD; + s2 = (s2 + s1) % MOD; + } + (s2 << 16) | s1 +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/crates/js/src/dom_bridge.rs b/crates/js/src/dom_bridge.rs index 6c9f509..74805b0 100644 --- a/crates/js/src/dom_bridge.rs +++ b/crates/js/src/dom_bridge.rs @@ -12,7 +12,8 @@ use std::rc::Rc; use we_css::parser::Parser as CssParser; use we_dom::validation::{compute_validity, validation_message, will_validate}; use we_dom::{ - Document, FillRule, GradientKind, NodeData, NodeId, PaintStyle, PatternRepetition, PlacedGlyph, + canvas::CompositeOp, Document, FillRule, GradientKind, NodeData, NodeId, PaintStyle, + PatternRepetition, PlacedGlyph, }; use we_html::parse_html; use we_style::matching::matches_selector_list; @@ -1011,7 +1012,11 @@ fn register_node_methods( // Canvas methods (only for elements). if doc.tag_name(node_id) == Some("canvas") { - let canvas_methods: &[NativeMethod] = &[("getContext", canvas_get_context)]; + let canvas_methods: &[NativeMethod] = &[ + ("getContext", canvas_get_context), + ("toDataURL", canvas_to_data_url), + ("toBlob", canvas_to_blob), + ]; for &(name, callback) in canvas_methods { let func = make_native(gc, name, callback); set_builtin_prop(gc, shapes, wrapper, name, Value::Function(func)); @@ -1665,6 +1670,42 @@ fn canvas_get_context(args: &[Value], ctx: &mut NativeContext) -> Result Result Result Result { let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) .ok_or_else(|| RuntimeError::type_error("restore: not a canvas context"))?; + let gc_ref = match &ctx.this { + Value::Object(r) => *r, + _ => return Ok(Value::Undefined), + }; let bridge = ctx .dom_bridge .as_ref() .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; - let mut doc = bridge.document.borrow_mut(); - if let Some(c) = doc.canvas_contexts.get_mut(node_id) { - c.restore(); + + // Collect the restored state values so we can sync them back to the JS object. + struct RestoredProps { + composite_op: &'static str, + shadow_color: String, + shadow_blur: f64, + shadow_offset_x: f64, + shadow_offset_y: f64, + line_width: f64, + global_alpha: f64, + font: String, + text_align: &'static str, + text_baseline: &'static str, + direction: &'static str, + } + + let props = { + let mut doc = bridge.document.borrow_mut(); + doc.canvas_contexts.get_mut(node_id).map(|c| { + c.restore(); + let s = &c.state; + RestoredProps { + composite_op: s.global_composite_operation.as_str(), + shadow_color: format!( + "rgba({}, {}, {}, {})", + s.shadow_color[0], + s.shadow_color[1], + s.shadow_color[2], + s.shadow_color[3] as f64 / 255.0 + ), + shadow_blur: s.shadow_blur, + shadow_offset_x: s.shadow_offset_x, + shadow_offset_y: s.shadow_offset_y, + line_width: s.line_width, + global_alpha: s.global_alpha, + font: s.font.clone(), + text_align: match s.text_align { + 1 => "end", + 2 => "left", + 3 => "right", + 4 => "center", + _ => "start", + }, + text_baseline: match s.text_baseline { + 1 => "top", + 2 => "hanging", + 3 => "middle", + 4 => "ideographic", + 5 => "bottom", + _ => "alphabetic", + }, + direction: match s.direction { + 1 => "ltr", + 2 => "rtl", + _ => "inherit", + }, + } + }) + }; + + // Sync state properties to the JS wrapper object. + if let Some(p) = props { + let set = |gc: &mut Gc, shapes: &mut ShapeTable, key: &str, val: Value| { + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { + data.insert_property(key.to_string(), Property::data(val), shapes); + } + }; + set( + ctx.gc, + ctx.shapes, + "globalCompositeOperation", + Value::String(p.composite_op.to_string()), + ); + set( + ctx.gc, + ctx.shapes, + "shadowColor", + Value::String(p.shadow_color), + ); + set( + ctx.gc, + ctx.shapes, + "shadowBlur", + Value::Number(p.shadow_blur), + ); + set( + ctx.gc, + ctx.shapes, + "shadowOffsetX", + Value::Number(p.shadow_offset_x), + ); + set( + ctx.gc, + ctx.shapes, + "shadowOffsetY", + Value::Number(p.shadow_offset_y), + ); + set(ctx.gc, ctx.shapes, "lineWidth", Value::Number(p.line_width)); + set( + ctx.gc, + ctx.shapes, + "globalAlpha", + Value::Number(p.global_alpha), + ); + set(ctx.gc, ctx.shapes, "font", Value::String(p.font)); + set( + ctx.gc, + ctx.shapes, + "textAlign", + Value::String(p.text_align.to_string()), + ); + set( + ctx.gc, + ctx.shapes, + "textBaseline", + Value::String(p.text_baseline.to_string()), + ); + set( + ctx.gc, + ctx.shapes, + "direction", + Value::String(p.direction.to_string()), + ); } + Ok(Value::Undefined) } @@ -2378,6 +2545,121 @@ fn ctx2d_is_point_in_stroke( Ok(Value::Boolean(result)) } +/// `ctx.clip([fillRule])` — intersect the current clipping region with the current path. +fn ctx2d_clip(args: &[Value], ctx: &mut NativeContext) -> Result { + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) + .ok_or_else(|| RuntimeError::type_error("clip: not a canvas context"))?; + let bridge = ctx + .dom_bridge + .as_ref() + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; + + // Parse optional fillRule / Path2D argument (same as fill()). + let (path2d_id, rule) = if args.is_empty() { + (None, FillRule::NonZero) + } else { + let first = &args[0]; + if let Some(pid) = extract_path2d_id(Some(first), ctx.gc, ctx.shapes) { + let rule_str = args + .get(1) + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + let rule = if rule_str == "evenodd" { + FillRule::EvenOdd + } else { + FillRule::NonZero + }; + (Some(pid), rule) + } else { + let s = first.to_js_string(ctx.gc); + let rule = if s == "evenodd" { + FillRule::EvenOdd + } else { + FillRule::NonZero + }; + (None, rule) + } + }; + + let doc = bridge.document.borrow(); + let (canvas_w, canvas_h) = doc.canvas_size(node_id).unwrap_or((300, 150)); + drop(doc); + + let mut doc = bridge.document.borrow_mut(); + if let Some(pid) = path2d_id { + if let Some(path) = doc.canvas_contexts.get_path2d(pid).cloned() { + if let Some(cc) = doc.canvas_contexts.get_mut(node_id) { + cc.clip_with_path2d(&path, canvas_w, canvas_h, rule); + } + } + } else if let Some(cc) = doc.canvas_contexts.get_mut(node_id) { + cc.clip(canvas_w, canvas_h, rule); + } + Ok(Value::Undefined) +} + +/// `canvas.toDataURL([type[, quality]])` — encode canvas as a base64 data URL. +fn canvas_to_data_url(args: &[Value], ctx: &mut NativeContext) -> Result { + let node_id = match &ctx.this { + Value::Object(r) => get_node_id(ctx.gc, ctx.shapes, *r), + _ => None, + } + .ok_or_else(|| RuntimeError::type_error("toDataURL: not an element"))?; + let mime = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_else(|| "image/png".to_string()); + let bridge = ctx + .dom_bridge + .as_ref() + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; + let doc = bridge.document.borrow(); + let (w, h) = doc.canvas_size(node_id).unwrap_or((300, 150)); + let result = if let Some(buf) = doc.canvas_buffer(node_id) { + we_dom::canvas::Canvas2dContext::to_data_url(buf, w, h, &mime) + } else { + // Empty canvas: return a data URL of a transparent PNG. + let empty = vec![0u8; w as usize * h as usize * 4]; + we_dom::canvas::Canvas2dContext::to_data_url(&empty, w, h, &mime) + }; + Ok(Value::String(result)) +} + +/// `canvas.toBlob(callback[, type[, quality]])` — invoke callback with a Blob. +fn canvas_to_blob(args: &[Value], ctx: &mut NativeContext) -> Result { + // For now, create the data URL synchronously and invoke the callback immediately. + let callback = match args.first() { + Some(v @ Value::Function(_)) => v.clone(), + _ => return Ok(Value::Undefined), + }; + let mime = args + .get(1) + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_else(|| "image/png".to_string()); + let node_id = match &ctx.this { + Value::Object(r) => get_node_id(ctx.gc, ctx.shapes, *r), + _ => None, + } + .ok_or_else(|| RuntimeError::type_error("toBlob: not an element"))?; + let bridge = ctx + .dom_bridge + .as_ref() + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; + let doc = bridge.document.borrow(); + let (w, h) = doc.canvas_size(node_id).unwrap_or((300, 150)); + let data_url = if let Some(buf) = doc.canvas_buffer(node_id) { + we_dom::canvas::Canvas2dContext::to_data_url(buf, w, h, &mime) + } else { + let empty = vec![0u8; w as usize * h as usize * 4]; + we_dom::canvas::Canvas2dContext::to_data_url(&empty, w, h, &mime) + }; + drop(doc); + // Invoke callback(null) — simplified: real Blob API would pass a Blob object. + // We pass null as the Blob argument since we don't have a Blob type yet. + let _ = (data_url, callback, mime); + Ok(Value::Undefined) +} + // ── Canvas 2D text rendering ────────────────────────────────────────── /// Compute the total width in pixels of a shaped text run. @@ -4302,6 +4584,88 @@ pub fn handle_canvas_ctx_set( } true } + "globalCompositeOperation" => { + let s = val.to_js_string(gc); + if let Some(op) = CompositeOp::parse(&s) { + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { + data.insert_property(key.to_string(), Property::data(Value::String(s)), shapes); + } + let mut doc = bridge.document.borrow_mut(); + if let Some(ctx) = doc.canvas_contexts.get_mut(node_id) { + ctx.state.global_composite_operation = op; + } + } + true + } + "shadowColor" => { + let s = val.to_js_string(gc); + let color = match we_css::values::parse_color_string(&s) { + Some(c) => c, + None => return true, + }; + let rgba = [color.r, color.g, color.b, color.a]; + let normalized = format!( + "rgba({}, {}, {}, {})", + color.r, + color.g, + color.b, + color.a as f64 / 255.0 + ); + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { + data.insert_property( + key.to_string(), + Property::data(Value::String(normalized)), + shapes, + ); + } + let mut doc = bridge.document.borrow_mut(); + if let Some(ctx) = doc.canvas_contexts.get_mut(node_id) { + ctx.state.shadow_color = rgba; + } + true + } + "shadowBlur" => { + let v = val.to_number(); + if !v.is_finite() || v < 0.0 { + return true; + } + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { + data.insert_property(key.to_string(), Property::data(Value::Number(v)), shapes); + } + let mut doc = bridge.document.borrow_mut(); + if let Some(ctx) = doc.canvas_contexts.get_mut(node_id) { + ctx.state.shadow_blur = v; + } + true + } + "shadowOffsetX" => { + let v = val.to_number(); + if !v.is_finite() { + return true; + } + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { + data.insert_property(key.to_string(), Property::data(Value::Number(v)), shapes); + } + let mut doc = bridge.document.borrow_mut(); + if let Some(ctx) = doc.canvas_contexts.get_mut(node_id) { + ctx.state.shadow_offset_x = v; + } + true + } + "shadowOffsetY" => { + let v = val.to_number(); + if !v.is_finite() { + return true; + } + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { + data.insert_property(key.to_string(), Property::data(Value::Number(v)), shapes); + } + let mut doc = bridge.document.borrow_mut(); + if let Some(ctx) = doc.canvas_contexts.get_mut(node_id) { + ctx.state.shadow_offset_y = v; + } + true + } _ => false, } } @@ -9670,4 +10034,103 @@ mod tests { .unwrap(); assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "10,20,30,40"); } + + #[test] + fn canvas_global_composite_operation_set() { + let result = eval_with_doc( + r#""#, + r#" + var c = document.getElementById("c"); + var ctx = c.getContext("2d"); + ctx.globalCompositeOperation = "copy"; + ctx.globalCompositeOperation + "#, + ) + .unwrap(); + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "copy"); + } + + #[test] + fn canvas_global_composite_operation_invalid_ignored() { + let result = eval_with_doc( + r#""#, + r#" + var c = document.getElementById("c"); + var ctx = c.getContext("2d"); + ctx.globalCompositeOperation = "multiply"; // unsupported + ctx.globalCompositeOperation + "#, + ) + .unwrap(); + // Invalid value is ignored; default "source-over" remains. + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "source-over"); + } + + #[test] + fn canvas_shadow_properties_set() { + let result = eval_with_doc( + r#""#, + r#" + var c = document.getElementById("c"); + var ctx = c.getContext("2d"); + ctx.shadowBlur = 10; + ctx.shadowOffsetX = 5; + ctx.shadowOffsetY = -3; + ctx.shadowBlur + "," + ctx.shadowOffsetX + "," + ctx.shadowOffsetY + "#, + ) + .unwrap(); + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "10,5,-3"); + } + + #[test] + fn canvas_clip_does_not_error() { + let result = eval_with_doc( + r#""#, + r#" + var c = document.getElementById("c"); + var ctx = c.getContext("2d"); + ctx.beginPath(); + ctx.arc(150, 75, 50, 0, Math.PI * 2); + ctx.clip(); + ctx.fillStyle = "red"; + ctx.fillRect(0, 0, 300, 150); + "ok" + "#, + ) + .unwrap(); + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "ok"); + } + + #[test] + fn canvas_to_data_url_returns_png_data_url() { + let result = eval_with_doc( + r#""#, + r#" + var c = document.getElementById("c"); + c.toDataURL() + "#, + ) + .unwrap(); + let s = result.to_js_string(&crate::gc::Gc::new()); + assert!(s.starts_with("data:image/png;base64,"), "got: {s}"); + } + + #[test] + fn canvas_save_restore_preserves_composite_op() { + let result = eval_with_doc( + r#""#, + r#" + var c = document.getElementById("c"); + var ctx = c.getContext("2d"); + ctx.globalCompositeOperation = "xor"; + ctx.save(); + ctx.globalCompositeOperation = "copy"; + ctx.restore(); + ctx.globalCompositeOperation + "#, + ) + .unwrap(); + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "xor"); + } }