we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement canvas rectangle operations & color fill/stroke styles (Phase 18)

Add fillRect(), strokeRect(), clearRect() drawing operations and
fillStyle/strokeStyle/lineWidth/globalAlpha property support to the
Canvas 2D API.

- CSS crate: add parse_color_string() for canvas color parsing (hex,
rgb/rgba, named colors, transparent) with hex digit validation
- DOM crate: add rasterization functions with affine transform support,
source-over alpha blending, axis-aligned fast path, and general
inverse-mapping path for rotated/skewed transforms
- JS crate: wire up fillRect/strokeRect/clearRect native methods,
add property setter interception for fillStyle/strokeStyle/lineWidth/
globalAlpha with spec-compliant validation (invalid colors silently
ignored, non-finite lineWidth rejected)
- Tests: color parsing (12 tests), rectangle rasterization with
transforms and alpha blending (8 tests), JS integration (14 tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1108 -1
+171 -1
crates/css/src/values.rs
··· 565 565 // Named colors (CSS Level 1 + transparent) 566 566 // --------------------------------------------------------------------------- 567 567 568 - fn named_color(name: &str) -> Option<Color> { 568 + pub fn named_color(name: &str) -> Option<Color> { 569 569 Some(match name { 570 570 "black" => Color::rgb(0, 0, 0), 571 571 "silver" => Color::rgb(192, 192, 192), ··· 586 586 "orange" => Color::rgb(255, 165, 0), 587 587 _ => return Option::None, 588 588 }) 589 + } 590 + 591 + // --------------------------------------------------------------------------- 592 + // Canvas color string parsing 593 + // --------------------------------------------------------------------------- 594 + 595 + /// Parse a CSS color string as used by the Canvas 2D API (`fillStyle`, 596 + /// `strokeStyle`). Supports `#rgb`, `#rrggbb`, `#rgba`, `#rrggbbaa`, 597 + /// `rgb()`, `rgba()`, named colors, and `transparent`. 598 + /// 599 + /// Returns `None` for invalid / unrecognised strings (per spec, the previous 600 + /// value should be kept). 601 + pub fn parse_color_string(s: &str) -> Option<Color> { 602 + let s = s.trim(); 603 + if s.is_empty() { 604 + return None; 605 + } 606 + 607 + // "transparent" 608 + if s.eq_ignore_ascii_case("transparent") { 609 + return Some(Color::new(0, 0, 0, 0)); 610 + } 611 + 612 + // Hex: #rgb, #rgba, #rrggbb, #rrggbbaa 613 + if let Some(hex) = s.strip_prefix('#') { 614 + // Validate all characters are hex digits before parsing. 615 + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { 616 + return None; 617 + } 618 + return match parse_hex_color(hex) { 619 + CssValue::Color(c) => Some(c), 620 + _ => None, 621 + }; 622 + } 623 + 624 + // rgb(...) / rgba(...) 625 + let lower = s.to_ascii_lowercase(); 626 + if lower.starts_with("rgb") { 627 + // Extract contents between parens 628 + if let Some(start) = s.find('(') { 629 + if let Some(end) = s.rfind(')') { 630 + if start < end { 631 + let inner = &s[start + 1..end]; 632 + let args = tokenize_color_args(inner); 633 + let result = parse_rgb(&args, lower.starts_with("rgba")); 634 + return match result { 635 + CssValue::Color(c) => Some(c), 636 + _ => None, 637 + }; 638 + } 639 + } 640 + } 641 + return None; 642 + } 643 + 644 + // Named colors 645 + named_color(&lower) 646 + } 647 + 648 + /// Tokenize the inner arguments of `rgb()` / `rgba()` into `ComponentValue`s 649 + /// that `parse_rgb` expects. 650 + fn tokenize_color_args(s: &str) -> Vec<ComponentValue> { 651 + use crate::tokenizer::NumericType; 652 + let mut result = Vec::new(); 653 + for part in s.split(|c: char| c == ',' || c.is_ascii_whitespace()) { 654 + let part = part.trim(); 655 + if part.is_empty() { 656 + continue; 657 + } 658 + if let Some(pct) = part.strip_suffix('%') { 659 + if let Ok(n) = pct.parse::<f64>() { 660 + result.push(ComponentValue::Percentage(n)); 661 + continue; 662 + } 663 + } 664 + if let Ok(n) = part.parse::<f64>() { 665 + let nt = if part.contains('.') { 666 + NumericType::Number 667 + } else { 668 + NumericType::Integer 669 + }; 670 + result.push(ComponentValue::Number(n, nt)); 671 + continue; 672 + } 673 + } 674 + result 589 675 } 590 676 591 677 // --------------------------------------------------------------------------- ··· 2070 2156 _ => panic!("expected style rule"), 2071 2157 }; 2072 2158 rule.declarations[0].value.clone() 2159 + } 2160 + 2161 + // -- Canvas color string parsing tests ---------------------------------- 2162 + 2163 + #[test] 2164 + fn canvas_color_hex_rgb() { 2165 + assert_eq!(parse_color_string("#ff0000"), Some(Color::rgb(255, 0, 0))); 2166 + } 2167 + 2168 + #[test] 2169 + fn canvas_color_hex_short() { 2170 + assert_eq!(parse_color_string("#f00"), Some(Color::rgb(255, 0, 0))); 2171 + } 2172 + 2173 + #[test] 2174 + fn canvas_color_hex_rgba() { 2175 + assert_eq!( 2176 + parse_color_string("#ff000080"), 2177 + Some(Color::new(255, 0, 0, 128)) 2178 + ); 2179 + } 2180 + 2181 + #[test] 2182 + fn canvas_color_hex_short_rgba() { 2183 + assert_eq!( 2184 + parse_color_string("#f008"), 2185 + Some(Color::new(255, 0, 0, 136)) 2186 + ); 2187 + } 2188 + 2189 + #[test] 2190 + fn canvas_color_named_red() { 2191 + assert_eq!(parse_color_string("red"), Some(Color::rgb(255, 0, 0))); 2192 + } 2193 + 2194 + #[test] 2195 + fn canvas_color_named_blue() { 2196 + assert_eq!(parse_color_string("blue"), Some(Color::rgb(0, 0, 255))); 2197 + } 2198 + 2199 + #[test] 2200 + fn canvas_color_transparent() { 2201 + assert_eq!( 2202 + parse_color_string("transparent"), 2203 + Some(Color::new(0, 0, 0, 0)) 2204 + ); 2205 + } 2206 + 2207 + #[test] 2208 + fn canvas_color_rgb_func() { 2209 + assert_eq!( 2210 + parse_color_string("rgb(128, 64, 32)"), 2211 + Some(Color::rgb(128, 64, 32)) 2212 + ); 2213 + } 2214 + 2215 + #[test] 2216 + fn canvas_color_rgba_func() { 2217 + let c = parse_color_string("rgba(255, 0, 0, 0.5)").unwrap(); 2218 + assert_eq!(c.r, 255); 2219 + assert_eq!(c.g, 0); 2220 + assert_eq!(c.b, 0); 2221 + assert_eq!(c.a, 128); 2222 + } 2223 + 2224 + #[test] 2225 + fn canvas_color_invalid_returns_none() { 2226 + assert_eq!(parse_color_string("notacolor"), None); 2227 + assert_eq!(parse_color_string(""), None); 2228 + assert_eq!(parse_color_string("#xyz"), None); 2229 + } 2230 + 2231 + #[test] 2232 + fn canvas_color_case_insensitive() { 2233 + assert_eq!(parse_color_string("RED"), Some(Color::rgb(255, 0, 0))); 2234 + assert_eq!( 2235 + parse_color_string("Transparent"), 2236 + Some(Color::new(0, 0, 0, 0)) 2237 + ); 2238 + } 2239 + 2240 + #[test] 2241 + fn canvas_color_whitespace_trimmed() { 2242 + assert_eq!(parse_color_string(" red "), Some(Color::rgb(255, 0, 0))); 2073 2243 } 2074 2244 }
+480
crates/dom/src/canvas.rs
··· 272 272 pub fn get_transform(&self) -> AffineTransform { 273 273 self.state.transform 274 274 } 275 + 276 + // ── Drawing operations ─────────────────────────────── 277 + 278 + /// `fillRect(x, y, w, h)` — fill a rectangle with the current fillStyle. 279 + /// 280 + /// The rectangle corners are transformed through the CTM before rasterization. 281 + /// Zero-width or zero-height rectangles are no-ops (per spec). 282 + pub fn fill_rect(&self, buf: &mut [u8], canvas_w: u32, canvas_h: u32, rect: [f64; 4]) { 283 + let [x, y, w, h] = rect; 284 + if w == 0.0 || h == 0.0 { 285 + return; 286 + } 287 + let color = apply_global_alpha(self.state.fill_style, self.state.global_alpha); 288 + rasterize_filled_rect( 289 + buf, 290 + canvas_w, 291 + canvas_h, 292 + &self.state.transform, 293 + x, 294 + y, 295 + w, 296 + h, 297 + color, 298 + ); 299 + } 300 + 301 + /// `strokeRect(x, y, w, h)` — stroke a rectangle outline with the current strokeStyle. 302 + /// 303 + /// Zero-width or zero-height rectangles are no-ops (per spec). 304 + pub fn stroke_rect(&self, buf: &mut [u8], canvas_w: u32, canvas_h: u32, rect: [f64; 4]) { 305 + let [x, y, w, h] = rect; 306 + if w == 0.0 || h == 0.0 { 307 + return; 308 + } 309 + let color = apply_global_alpha(self.state.stroke_style, self.state.global_alpha); 310 + let lw = self.state.line_width; 311 + rasterize_stroke_rect( 312 + buf, 313 + canvas_w, 314 + canvas_h, 315 + &self.state.transform, 316 + x, 317 + y, 318 + w, 319 + h, 320 + color, 321 + lw, 322 + ); 323 + } 324 + 325 + /// `clearRect(x, y, w, h)` — clear pixels to transparent black. 326 + /// 327 + /// Zero-width or zero-height is a no-op. 328 + pub fn clear_rect(&self, buf: &mut [u8], canvas_w: u32, canvas_h: u32, rect: [f64; 4]) { 329 + let [x, y, w, h] = rect; 330 + if w == 0.0 || h == 0.0 { 331 + return; 332 + } 333 + rasterize_clear_rect(buf, canvas_w, canvas_h, &self.state.transform, x, y, w, h); 334 + } 335 + } 336 + 337 + // ── Rasterization helpers ──────────────────────────────────────────── 338 + 339 + /// Blend a source RGBA color over a destination pixel using source-over compositing. 340 + #[inline] 341 + fn blend_source_over(dst: &mut [u8], src: [u8; 4]) { 342 + let sa = src[3] as u32; 343 + if sa == 0 { 344 + return; 345 + } 346 + if sa == 255 { 347 + dst[0] = src[0]; 348 + dst[1] = src[1]; 349 + dst[2] = src[2]; 350 + dst[3] = 255; 351 + return; 352 + } 353 + let da = dst[3] as u32; 354 + let inv_sa = 255 - sa; 355 + // out_a = sa + da * (1 - sa/255) 356 + let out_a = sa + (da * inv_sa + 127) / 255; 357 + if out_a == 0 { 358 + dst[0] = 0; 359 + dst[1] = 0; 360 + dst[2] = 0; 361 + dst[3] = 0; 362 + return; 363 + } 364 + for i in 0..3 { 365 + let sc = src[i] as u32; 366 + let dc = dst[i] as u32; 367 + dst[i] = ((sc * sa + dc * da * inv_sa / 255 + out_a / 2) / out_a).min(255) as u8; 368 + } 369 + dst[3] = out_a.min(255) as u8; 370 + } 371 + 372 + /// Apply globalAlpha to a color. 373 + #[inline] 374 + fn apply_global_alpha(color: [u8; 4], global_alpha: f64) -> [u8; 4] { 375 + if global_alpha >= 1.0 { 376 + return color; 377 + } 378 + let a = (color[3] as f64 * global_alpha.clamp(0.0, 1.0)).round() as u8; 379 + [color[0], color[1], color[2], a] 380 + } 381 + 382 + /// Rasterize a filled, transformed rectangle into the backing buffer. 383 + /// `color` should already have `globalAlpha` applied. 384 + #[allow(clippy::too_many_arguments)] 385 + fn rasterize_filled_rect( 386 + buf: &mut [u8], 387 + canvas_w: u32, 388 + canvas_h: u32, 389 + transform: &AffineTransform, 390 + x: f64, 391 + y: f64, 392 + w: f64, 393 + h: f64, 394 + color: [u8; 4], 395 + ) { 396 + if color[3] == 0 { 397 + return; 398 + } 399 + 400 + // Transform the four corners of the rectangle. 401 + let corners = [ 402 + transform.apply(x, y), 403 + transform.apply(x + w, y), 404 + transform.apply(x + w, y + h), 405 + transform.apply(x, y + h), 406 + ]; 407 + 408 + // Find axis-aligned bounding box of the transformed quad. 409 + let min_x = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min); 410 + let max_x = corners 411 + .iter() 412 + .map(|c| c.0) 413 + .fold(f64::NEG_INFINITY, f64::max); 414 + let min_y = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min); 415 + let max_y = corners 416 + .iter() 417 + .map(|c| c.1) 418 + .fold(f64::NEG_INFINITY, f64::max); 419 + 420 + // Clamp to canvas bounds. 421 + let px_min_x = (min_x.floor() as i32).max(0); 422 + let px_max_x = (max_x.ceil() as i32).min(canvas_w as i32); 423 + let px_min_y = (min_y.floor() as i32).max(0); 424 + let px_max_y = (max_y.ceil() as i32).min(canvas_h as i32); 425 + 426 + // If the transform is axis-aligned (no rotation/skew), use the fast path. 427 + if transform.b == 0.0 && transform.c == 0.0 { 428 + for py in px_min_y..px_max_y { 429 + let row_start = (py as usize * canvas_w as usize + px_min_x as usize) * 4; 430 + for px in px_min_x..px_max_x { 431 + let idx = row_start + (px - px_min_x) as usize * 4; 432 + if idx + 3 < buf.len() { 433 + blend_source_over(&mut buf[idx..idx + 4], color); 434 + } 435 + } 436 + } 437 + return; 438 + } 439 + 440 + // General path: inverse-map each pixel back to see if it falls inside the rect. 441 + let inv = match transform.inverse() { 442 + Some(inv) => inv, 443 + None => return, // Singular transform — nothing visible. 444 + }; 445 + 446 + for py in px_min_y..px_max_y { 447 + for px in px_min_x..px_max_x { 448 + let (ox, oy) = inv.apply(px as f64 + 0.5, py as f64 + 0.5); 449 + if ox >= x && ox < x + w && oy >= y && oy < y + h { 450 + let idx = (py as usize * canvas_w as usize + px as usize) * 4; 451 + if idx + 3 < buf.len() { 452 + blend_source_over(&mut buf[idx..idx + 4], color); 453 + } 454 + } 455 + } 456 + } 457 + } 458 + 459 + /// Rasterize a stroked rectangle outline into the backing buffer. 460 + /// `color` should already have `globalAlpha` applied. 461 + #[allow(clippy::too_many_arguments)] 462 + fn rasterize_stroke_rect( 463 + buf: &mut [u8], 464 + canvas_w: u32, 465 + canvas_h: u32, 466 + transform: &AffineTransform, 467 + x: f64, 468 + y: f64, 469 + w: f64, 470 + h: f64, 471 + color: [u8; 4], 472 + line_width: f64, 473 + ) { 474 + // A stroke rect is four filled rectangles (the edges). 475 + // The stroke is centered on the edge (half inside, half outside). 476 + let half = line_width / 2.0; 477 + 478 + // Top edge 479 + rasterize_filled_rect( 480 + buf, 481 + canvas_w, 482 + canvas_h, 483 + transform, 484 + x - half, 485 + y - half, 486 + w + line_width, 487 + line_width, 488 + color, 489 + ); 490 + // Bottom edge 491 + rasterize_filled_rect( 492 + buf, 493 + canvas_w, 494 + canvas_h, 495 + transform, 496 + x - half, 497 + y + h - half, 498 + w + line_width, 499 + line_width, 500 + color, 501 + ); 502 + // Left edge (between top and bottom to avoid corner overlap) 503 + rasterize_filled_rect( 504 + buf, 505 + canvas_w, 506 + canvas_h, 507 + transform, 508 + x - half, 509 + y + half, 510 + line_width, 511 + h - line_width, 512 + color, 513 + ); 514 + // Right edge 515 + rasterize_filled_rect( 516 + buf, 517 + canvas_w, 518 + canvas_h, 519 + transform, 520 + x + w - half, 521 + y + half, 522 + line_width, 523 + h - line_width, 524 + color, 525 + ); 526 + } 527 + 528 + /// Clear a rectangle to transparent black (RGBA 0,0,0,0). 529 + #[allow(clippy::too_many_arguments)] 530 + fn rasterize_clear_rect( 531 + buf: &mut [u8], 532 + canvas_w: u32, 533 + canvas_h: u32, 534 + transform: &AffineTransform, 535 + x: f64, 536 + y: f64, 537 + w: f64, 538 + h: f64, 539 + ) { 540 + // Transform corners and find bounding box. 541 + let corners = [ 542 + transform.apply(x, y), 543 + transform.apply(x + w, y), 544 + transform.apply(x + w, y + h), 545 + transform.apply(x, y + h), 546 + ]; 547 + 548 + let min_x = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min); 549 + let max_x = corners 550 + .iter() 551 + .map(|c| c.0) 552 + .fold(f64::NEG_INFINITY, f64::max); 553 + let min_y = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min); 554 + let max_y = corners 555 + .iter() 556 + .map(|c| c.1) 557 + .fold(f64::NEG_INFINITY, f64::max); 558 + 559 + let px_min_x = (min_x.floor() as i32).max(0); 560 + let px_max_x = (max_x.ceil() as i32).min(canvas_w as i32); 561 + let px_min_y = (min_y.floor() as i32).max(0); 562 + let px_max_y = (max_y.ceil() as i32).min(canvas_h as i32); 563 + 564 + // Axis-aligned fast path. 565 + if transform.b == 0.0 && transform.c == 0.0 { 566 + for py in px_min_y..px_max_y { 567 + let row_start = (py as usize * canvas_w as usize + px_min_x as usize) * 4; 568 + let row_end = row_start + (px_max_x - px_min_x) as usize * 4; 569 + if row_end <= buf.len() { 570 + buf[row_start..row_end].fill(0); 571 + } 572 + } 573 + return; 574 + } 575 + 576 + // General path with inverse mapping. 577 + let inv = match transform.inverse() { 578 + Some(inv) => inv, 579 + None => return, 580 + }; 581 + 582 + for py in px_min_y..px_max_y { 583 + for px in px_min_x..px_max_x { 584 + let (ox, oy) = inv.apply(px as f64 + 0.5, py as f64 + 0.5); 585 + if ox >= x && ox < x + w && oy >= y && oy < y + h { 586 + let idx = (py as usize * canvas_w as usize + px as usize) * 4; 587 + if idx + 3 < buf.len() { 588 + buf[idx] = 0; 589 + buf[idx + 1] = 0; 590 + buf[idx + 2] = 0; 591 + buf[idx + 3] = 0; 592 + } 593 + } 594 + } 595 + } 275 596 } 276 597 277 598 // ── Canvas context storage on Document ──────────────────────────── ··· 616 937 let ctx = store.get_or_create(node); 617 938 let t = ctx.get_transform(); 618 939 assert!(t.e.abs() < 1e-10); 940 + } 941 + 942 + // ── Drawing operation tests ────────────────────────── 943 + 944 + /// Helper: create a small canvas buffer. 945 + fn make_buf(w: u32, h: u32) -> Vec<u8> { 946 + vec![0u8; (w * h * 4) as usize] 947 + } 948 + 949 + /// Read the RGBA pixel at (x, y). 950 + fn pixel(buf: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] { 951 + let idx = (y * w + x) as usize * 4; 952 + [buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]] 953 + } 954 + 955 + #[test] 956 + fn fill_rect_basic() { 957 + let mut buf = make_buf(10, 10); 958 + let mut ctx = Canvas2dContext::new(); 959 + ctx.state.fill_style = [255, 0, 0, 255]; // opaque red 960 + ctx.fill_rect(&mut buf, 10, 10, [2.0, 3.0, 4.0, 2.0]); 961 + 962 + // Inside the rect 963 + assert_eq!(pixel(&buf, 10, 3, 4), [255, 0, 0, 255]); 964 + // Outside the rect 965 + assert_eq!(pixel(&buf, 10, 0, 0), [0, 0, 0, 0]); 966 + // Edge (2,3) should be inside 967 + assert_eq!(pixel(&buf, 10, 2, 3), [255, 0, 0, 255]); 968 + // (6,3) should be outside (x range is 2..6 exclusive of 6) 969 + assert_eq!(pixel(&buf, 10, 6, 3), [0, 0, 0, 0]); 970 + } 971 + 972 + #[test] 973 + fn fill_rect_zero_size_is_noop() { 974 + let mut buf = make_buf(10, 10); 975 + let ctx = Canvas2dContext::new(); 976 + ctx.fill_rect(&mut buf, 10, 10, [2.0, 2.0, 0.0, 5.0]); 977 + ctx.fill_rect(&mut buf, 10, 10, [2.0, 2.0, 5.0, 0.0]); 978 + // Buffer should be untouched 979 + assert!(buf.iter().all(|&b| b == 0)); 980 + } 981 + 982 + #[test] 983 + fn clear_rect_clears_to_transparent() { 984 + let mut buf = make_buf(10, 10); 985 + let mut ctx = Canvas2dContext::new(); 986 + // Fill the whole canvas red 987 + ctx.state.fill_style = [255, 0, 0, 255]; 988 + ctx.fill_rect(&mut buf, 10, 10, [0.0, 0.0, 10.0, 10.0]); 989 + assert_eq!(pixel(&buf, 10, 5, 5), [255, 0, 0, 255]); 990 + 991 + // Clear a sub-region 992 + ctx.clear_rect(&mut buf, 10, 10, [3.0, 3.0, 4.0, 4.0]); 993 + // Cleared region is transparent 994 + assert_eq!(pixel(&buf, 10, 4, 4), [0, 0, 0, 0]); 995 + // Outside cleared region still red 996 + assert_eq!(pixel(&buf, 10, 1, 1), [255, 0, 0, 255]); 997 + } 998 + 999 + #[test] 1000 + fn stroke_rect_draws_outline() { 1001 + let mut buf = make_buf(20, 20); 1002 + let mut ctx = Canvas2dContext::new(); 1003 + ctx.state.stroke_style = [0, 255, 0, 255]; // green 1004 + ctx.state.line_width = 1.0; 1005 + ctx.stroke_rect(&mut buf, 20, 20, [5.0, 5.0, 10.0, 10.0]); 1006 + 1007 + // A pixel on the top edge (y ≈ 5) 1008 + let p = pixel(&buf, 20, 10, 5); 1009 + assert_eq!(p, [0, 255, 0, 255]); 1010 + // Center should be empty (not filled) 1011 + assert_eq!(pixel(&buf, 20, 10, 10), [0, 0, 0, 0]); 1012 + } 1013 + 1014 + #[test] 1015 + fn fill_rect_with_translation() { 1016 + let mut buf = make_buf(20, 20); 1017 + let mut ctx = Canvas2dContext::new(); 1018 + ctx.state.fill_style = [0, 0, 255, 255]; // blue 1019 + ctx.translate(5.0, 5.0); 1020 + ctx.fill_rect(&mut buf, 20, 20, [0.0, 0.0, 3.0, 3.0]); 1021 + 1022 + // The rect should be at (5,5)–(7,7) in canvas coords 1023 + assert_eq!(pixel(&buf, 20, 6, 6), [0, 0, 255, 255]); 1024 + assert_eq!(pixel(&buf, 20, 0, 0), [0, 0, 0, 0]); 1025 + } 1026 + 1027 + #[test] 1028 + fn fill_rect_with_scale() { 1029 + let mut buf = make_buf(20, 20); 1030 + let mut ctx = Canvas2dContext::new(); 1031 + ctx.state.fill_style = [255, 255, 0, 255]; // yellow 1032 + ctx.scale(2.0, 2.0); 1033 + ctx.fill_rect(&mut buf, 20, 20, [1.0, 1.0, 3.0, 3.0]); 1034 + 1035 + // Scaled rect covers (2,2)–(8,8) in canvas coords 1036 + assert_eq!(pixel(&buf, 20, 4, 4), [255, 255, 0, 255]); 1037 + assert_eq!(pixel(&buf, 20, 0, 0), [0, 0, 0, 0]); 1038 + } 1039 + 1040 + #[test] 1041 + fn fill_rect_alpha_blending() { 1042 + let mut buf = make_buf(10, 10); 1043 + let mut ctx = Canvas2dContext::new(); 1044 + 1045 + // First: opaque red background 1046 + ctx.state.fill_style = [255, 0, 0, 255]; 1047 + ctx.fill_rect(&mut buf, 10, 10, [0.0, 0.0, 10.0, 10.0]); 1048 + 1049 + // Second: semi-transparent blue on top 1050 + ctx.state.fill_style = [0, 0, 255, 128]; 1051 + ctx.fill_rect(&mut buf, 10, 10, [0.0, 0.0, 10.0, 10.0]); 1052 + 1053 + let p = pixel(&buf, 10, 5, 5); 1054 + // After blending: red should decrease, blue should appear 1055 + assert!(p[0] < 255, "red channel should decrease"); 1056 + assert!(p[2] > 0, "blue channel should be present"); 1057 + assert_eq!(p[3], 255, "alpha should be fully opaque"); 1058 + } 1059 + 1060 + #[test] 1061 + fn fill_rect_global_alpha() { 1062 + let mut buf = make_buf(10, 10); 1063 + let mut ctx = Canvas2dContext::new(); 1064 + ctx.state.fill_style = [255, 0, 0, 255]; // opaque red 1065 + ctx.state.global_alpha = 0.5; 1066 + ctx.fill_rect(&mut buf, 10, 10, [0.0, 0.0, 10.0, 10.0]); 1067 + 1068 + let p = pixel(&buf, 10, 5, 5); 1069 + // With globalAlpha = 0.5 on transparent canvas, effective alpha ≈ 128 1070 + assert!( 1071 + p[3] > 100 && p[3] < 160, 1072 + "alpha should be ~128, got {}", 1073 + p[3] 1074 + ); 1075 + } 1076 + 1077 + #[test] 1078 + fn blend_source_over_fully_opaque() { 1079 + let mut dst = [100, 100, 100, 255]; 1080 + super::blend_source_over(&mut dst, [200, 50, 30, 255]); 1081 + assert_eq!(dst, [200, 50, 30, 255]); 1082 + } 1083 + 1084 + #[test] 1085 + fn blend_source_over_fully_transparent() { 1086 + let mut dst = [100, 100, 100, 255]; 1087 + super::blend_source_over(&mut dst, [200, 50, 30, 0]); 1088 + assert_eq!(dst, [100, 100, 100, 255]); 1089 + } 1090 + 1091 + #[test] 1092 + fn blend_source_over_partial() { 1093 + let mut dst = [255, 0, 0, 255]; // opaque red 1094 + super::blend_source_over(&mut dst, [0, 0, 255, 128]); // semi-transparent blue 1095 + // The result should have some red and some blue 1096 + assert!(dst[0] > 0 && dst[0] < 255); 1097 + assert!(dst[2] > 0 && dst[2] < 255); 1098 + assert_eq!(dst[3], 255); 619 1099 } 620 1100 }
+446
crates/js/src/dom_bridge.rs
··· 1278 1278 set_builtin_prop(ctx.gc, ctx.shapes, ctx_ref, "canvas", Value::Object(*r)); 1279 1279 } 1280 1280 1281 + // Set initial style/drawing properties on the wrapper. 1282 + set_builtin_prop( 1283 + ctx.gc, 1284 + ctx.shapes, 1285 + ctx_ref, 1286 + "fillStyle", 1287 + Value::String("#000000".to_string()), 1288 + ); 1289 + set_builtin_prop( 1290 + ctx.gc, 1291 + ctx.shapes, 1292 + ctx_ref, 1293 + "strokeStyle", 1294 + Value::String("#000000".to_string()), 1295 + ); 1296 + set_builtin_prop(ctx.gc, ctx.shapes, ctx_ref, "lineWidth", Value::Number(1.0)); 1297 + set_builtin_prop( 1298 + ctx.gc, 1299 + ctx.shapes, 1300 + ctx_ref, 1301 + "globalAlpha", 1302 + Value::Number(1.0), 1303 + ); 1304 + 1281 1305 // Register context2d methods on the wrapper. 1282 1306 let ctx2d_methods: &[NativeMethod] = &[ 1283 1307 ("save", ctx2d_save), ··· 1289 1313 ("setTransform", ctx2d_set_transform), 1290 1314 ("resetTransform", ctx2d_reset_transform), 1291 1315 ("getTransform", ctx2d_get_transform), 1316 + ("fillRect", ctx2d_fill_rect), 1317 + ("strokeRect", ctx2d_stroke_rect), 1318 + ("clearRect", ctx2d_clear_rect), 1292 1319 ]; 1293 1320 for &(name, callback) in ctx2d_methods { 1294 1321 let func = make_native(ctx.gc, name, callback); ··· 1468 1495 ); 1469 1496 } 1470 1497 Ok(Value::Object(ctx.gc.alloc(HeapObject::Object(matrix)))) 1498 + } 1499 + 1500 + // ── Canvas drawing operations ────────────────────────────────────── 1501 + 1502 + /// `ctx.fillRect(x, y, w, h)` — fill a rectangle with the current fillStyle. 1503 + fn ctx2d_fill_rect(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1504 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1505 + .ok_or_else(|| RuntimeError::type_error("fillRect: not a canvas context"))?; 1506 + let x = args.first().map(|v| v.to_number()).unwrap_or(0.0); 1507 + let y = args.get(1).map(|v| v.to_number()).unwrap_or(0.0); 1508 + let w = args.get(2).map(|v| v.to_number()).unwrap_or(0.0); 1509 + let h = args.get(3).map(|v| v.to_number()).unwrap_or(0.0); 1510 + 1511 + // Per spec: if any argument is non-finite, return without drawing. 1512 + if !x.is_finite() || !y.is_finite() || !w.is_finite() || !h.is_finite() { 1513 + return Ok(Value::Undefined); 1514 + } 1515 + 1516 + let bridge = ctx 1517 + .dom_bridge 1518 + .as_ref() 1519 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1520 + let mut doc = bridge.document.borrow_mut(); 1521 + let (cw, ch) = doc.canvas_size(node_id).unwrap_or((0, 0)); 1522 + if cw == 0 || ch == 0 { 1523 + return Ok(Value::Undefined); 1524 + } 1525 + 1526 + // Clone the context state to avoid borrow conflict. 1527 + let ctx_state = doc.canvas_contexts.get(node_id).cloned(); 1528 + if let Some(canvas_ctx) = ctx_state { 1529 + if let Some(buf) = doc.canvas_buffer_mut(node_id) { 1530 + canvas_ctx.fill_rect(buf, cw, ch, [x, y, w, h]); 1531 + } 1532 + } 1533 + Ok(Value::Undefined) 1534 + } 1535 + 1536 + /// `ctx.strokeRect(x, y, w, h)` — stroke a rectangle outline. 1537 + fn ctx2d_stroke_rect(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1538 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1539 + .ok_or_else(|| RuntimeError::type_error("strokeRect: not a canvas context"))?; 1540 + let x = args.first().map(|v| v.to_number()).unwrap_or(0.0); 1541 + let y = args.get(1).map(|v| v.to_number()).unwrap_or(0.0); 1542 + let w = args.get(2).map(|v| v.to_number()).unwrap_or(0.0); 1543 + let h = args.get(3).map(|v| v.to_number()).unwrap_or(0.0); 1544 + 1545 + if !x.is_finite() || !y.is_finite() || !w.is_finite() || !h.is_finite() { 1546 + return Ok(Value::Undefined); 1547 + } 1548 + 1549 + let bridge = ctx 1550 + .dom_bridge 1551 + .as_ref() 1552 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1553 + let mut doc = bridge.document.borrow_mut(); 1554 + let (cw, ch) = doc.canvas_size(node_id).unwrap_or((0, 0)); 1555 + if cw == 0 || ch == 0 { 1556 + return Ok(Value::Undefined); 1557 + } 1558 + 1559 + let ctx_state = doc.canvas_contexts.get(node_id).cloned(); 1560 + if let Some(canvas_ctx) = ctx_state { 1561 + if let Some(buf) = doc.canvas_buffer_mut(node_id) { 1562 + canvas_ctx.stroke_rect(buf, cw, ch, [x, y, w, h]); 1563 + } 1564 + } 1565 + Ok(Value::Undefined) 1566 + } 1567 + 1568 + /// `ctx.clearRect(x, y, w, h)` — clear a rectangle to transparent black. 1569 + fn ctx2d_clear_rect(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1570 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1571 + .ok_or_else(|| RuntimeError::type_error("clearRect: not a canvas context"))?; 1572 + let x = args.first().map(|v| v.to_number()).unwrap_or(0.0); 1573 + let y = args.get(1).map(|v| v.to_number()).unwrap_or(0.0); 1574 + let w = args.get(2).map(|v| v.to_number()).unwrap_or(0.0); 1575 + let h = args.get(3).map(|v| v.to_number()).unwrap_or(0.0); 1576 + 1577 + if !x.is_finite() || !y.is_finite() || !w.is_finite() || !h.is_finite() { 1578 + return Ok(Value::Undefined); 1579 + } 1580 + 1581 + let bridge = ctx 1582 + .dom_bridge 1583 + .as_ref() 1584 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1585 + let mut doc = bridge.document.borrow_mut(); 1586 + let (cw, ch) = doc.canvas_size(node_id).unwrap_or((0, 0)); 1587 + if cw == 0 || ch == 0 { 1588 + return Ok(Value::Undefined); 1589 + } 1590 + 1591 + let ctx_state = doc.canvas_contexts.get(node_id).cloned(); 1592 + if let Some(canvas_ctx) = ctx_state { 1593 + if let Some(buf) = doc.canvas_buffer_mut(node_id) { 1594 + canvas_ctx.clear_rect(buf, cw, ch, [x, y, w, h]); 1595 + } 1596 + } 1597 + Ok(Value::Undefined) 1471 1598 } 1472 1599 1473 1600 // ── HTML serialization ────────────────────────────────────────────── ··· 2436 2563 .set_attribute(node_id, "style", &style_str); 2437 2564 2438 2565 true 2566 + } 2567 + 2568 + // ── Canvas context property setter interception ──────────────────── 2569 + 2570 + /// Intercept property sets on CanvasRenderingContext2D wrappers. 2571 + /// 2572 + /// Handles `fillStyle`, `strokeStyle`, `lineWidth`, and `globalAlpha`. 2573 + /// Invalid color strings are silently ignored (per spec). 2574 + pub fn handle_canvas_ctx_set( 2575 + gc: &mut Gc<HeapObject>, 2576 + shapes: &mut ShapeTable, 2577 + bridge: &Rc<DomBridge>, 2578 + gc_ref: GcRef, 2579 + key: &str, 2580 + val: &Value, 2581 + ) -> bool { 2582 + // Check for __canvas_node_id__ marker. 2583 + let node_id = match gc.get(gc_ref) { 2584 + Some(HeapObject::Object(data)) => match data.get_property(CANVAS_NODE_ID_KEY, shapes) { 2585 + Some(Property { 2586 + value: Value::Number(n), 2587 + .. 2588 + }) => NodeId::from_index(n as usize), 2589 + _ => return false, 2590 + }, 2591 + _ => return false, 2592 + }; 2593 + 2594 + match key { 2595 + "fillStyle" | "strokeStyle" => { 2596 + let color_str = val.to_js_string(gc); 2597 + let color = match we_css::values::parse_color_string(&color_str) { 2598 + Some(c) => c, 2599 + None => return true, // Invalid color: silently ignore per spec. 2600 + }; 2601 + let rgba = [color.r, color.g, color.b, color.a]; 2602 + 2603 + // Normalize the color string for the getter. 2604 + let normalized = if color.a == 255 { 2605 + format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b) 2606 + } else { 2607 + format!( 2608 + "rgba({}, {}, {}, {})", 2609 + color.r, 2610 + color.g, 2611 + color.b, 2612 + color.a as f64 / 255.0 2613 + ) 2614 + }; 2615 + 2616 + // Update the wrapper's property value (normalized string). 2617 + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { 2618 + data.insert_property( 2619 + key.to_string(), 2620 + Property::data(Value::String(normalized)), 2621 + shapes, 2622 + ); 2623 + } 2624 + 2625 + // Update the Canvas2dState. 2626 + let mut doc = bridge.document.borrow_mut(); 2627 + if let Some(ctx) = doc.canvas_contexts.get_mut(node_id) { 2628 + if key == "fillStyle" { 2629 + ctx.state.fill_style = rgba; 2630 + } else { 2631 + ctx.state.stroke_style = rgba; 2632 + } 2633 + } 2634 + true 2635 + } 2636 + "lineWidth" => { 2637 + let w = val.to_number(); 2638 + // Per spec: non-finite, zero, or negative values are ignored. 2639 + if !w.is_finite() || w <= 0.0 { 2640 + return true; 2641 + } 2642 + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { 2643 + data.insert_property(key.to_string(), Property::data(Value::Number(w)), shapes); 2644 + } 2645 + let mut doc = bridge.document.borrow_mut(); 2646 + if let Some(ctx) = doc.canvas_contexts.get_mut(node_id) { 2647 + ctx.state.line_width = w; 2648 + } 2649 + true 2650 + } 2651 + "globalAlpha" => { 2652 + let a = val.to_number(); 2653 + // Per spec: values outside 0.0..=1.0 or non-finite are ignored. 2654 + if !a.is_finite() || !(0.0..=1.0).contains(&a) { 2655 + return true; 2656 + } 2657 + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { 2658 + data.insert_property(key.to_string(), Property::data(Value::Number(a)), shapes); 2659 + } 2660 + let mut doc = bridge.document.borrow_mut(); 2661 + if let Some(ctx) = doc.canvas_contexts.get_mut(node_id) { 2662 + ctx.state.global_alpha = a; 2663 + } 2664 + true 2665 + } 2666 + _ => false, 2667 + } 2439 2668 } 2440 2669 2441 2670 // ── classList helpers ─────────────────────────────────────────────── ··· 6493 6722 Value::Boolean(b) => assert!(b, "getTransform should return identity DOMMatrix-like"), 6494 6723 v => panic!("expected true, got {v:?}"), 6495 6724 } 6725 + } 6726 + 6727 + // ── Canvas drawing operation tests ──────────────────────────────── 6728 + 6729 + #[test] 6730 + fn canvas_fill_style_default() { 6731 + let result = eval_with_doc( 6732 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6733 + r#" 6734 + var c = document.getElementById("c"); 6735 + var ctx = c.getContext("2d"); 6736 + ctx.fillStyle 6737 + "#, 6738 + ) 6739 + .unwrap(); 6740 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "#000000"); 6741 + } 6742 + 6743 + #[test] 6744 + fn canvas_fill_style_set_named() { 6745 + let result = eval_with_doc( 6746 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6747 + r#" 6748 + var c = document.getElementById("c"); 6749 + var ctx = c.getContext("2d"); 6750 + ctx.fillStyle = "red"; 6751 + ctx.fillStyle 6752 + "#, 6753 + ) 6754 + .unwrap(); 6755 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "#ff0000"); 6756 + } 6757 + 6758 + #[test] 6759 + fn canvas_fill_style_invalid_ignored() { 6760 + let result = eval_with_doc( 6761 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6762 + r#" 6763 + var c = document.getElementById("c"); 6764 + var ctx = c.getContext("2d"); 6765 + ctx.fillStyle = "blue"; 6766 + ctx.fillStyle = "notacolor"; 6767 + ctx.fillStyle 6768 + "#, 6769 + ) 6770 + .unwrap(); 6771 + // Should still be blue (invalid color was silently ignored) 6772 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "#0000ff"); 6773 + } 6774 + 6775 + #[test] 6776 + fn canvas_stroke_style_set() { 6777 + let result = eval_with_doc( 6778 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6779 + r#" 6780 + var c = document.getElementById("c"); 6781 + var ctx = c.getContext("2d"); 6782 + ctx.strokeStyle = "orange"; 6783 + ctx.strokeStyle 6784 + "#, 6785 + ) 6786 + .unwrap(); 6787 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "#ffa500"); 6788 + } 6789 + 6790 + #[test] 6791 + fn canvas_line_width_default_and_set() { 6792 + let result = eval_with_doc( 6793 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6794 + r#" 6795 + var c = document.getElementById("c"); 6796 + var ctx = c.getContext("2d"); 6797 + var def = ctx.lineWidth; 6798 + ctx.lineWidth = 5; 6799 + def + "," + ctx.lineWidth 6800 + "#, 6801 + ) 6802 + .unwrap(); 6803 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "1,5"); 6804 + } 6805 + 6806 + #[test] 6807 + fn canvas_line_width_invalid_ignored() { 6808 + let result = eval_with_doc( 6809 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6810 + r#" 6811 + var c = document.getElementById("c"); 6812 + var ctx = c.getContext("2d"); 6813 + ctx.lineWidth = 3; 6814 + ctx.lineWidth = -1; 6815 + ctx.lineWidth = 0; 6816 + ctx.lineWidth 6817 + "#, 6818 + ) 6819 + .unwrap(); 6820 + match result { 6821 + Value::Number(n) => assert_eq!(n, 3.0), 6822 + v => panic!("expected 3, got {v:?}"), 6823 + } 6824 + } 6825 + 6826 + #[test] 6827 + fn canvas_global_alpha_set() { 6828 + let result = eval_with_doc( 6829 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6830 + r#" 6831 + var c = document.getElementById("c"); 6832 + var ctx = c.getContext("2d"); 6833 + ctx.globalAlpha = 0.5; 6834 + ctx.globalAlpha 6835 + "#, 6836 + ) 6837 + .unwrap(); 6838 + match result { 6839 + Value::Number(n) => assert!((n - 0.5).abs() < 1e-10), 6840 + v => panic!("expected 0.5, got {v:?}"), 6841 + } 6842 + } 6843 + 6844 + #[test] 6845 + fn canvas_fill_rect_runs() { 6846 + // Just verify fillRect doesn't error — actual pixel checking is in DOM tests. 6847 + let result = eval_with_doc( 6848 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6849 + r#" 6850 + var c = document.getElementById("c"); 6851 + var ctx = c.getContext("2d"); 6852 + ctx.fillStyle = "red"; 6853 + ctx.fillRect(10, 10, 100, 50); 6854 + "ok" 6855 + "#, 6856 + ) 6857 + .unwrap(); 6858 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "ok"); 6859 + } 6860 + 6861 + #[test] 6862 + fn canvas_stroke_rect_runs() { 6863 + let result = eval_with_doc( 6864 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6865 + r#" 6866 + var c = document.getElementById("c"); 6867 + var ctx = c.getContext("2d"); 6868 + ctx.strokeRect(10, 10, 100, 50); 6869 + "ok" 6870 + "#, 6871 + ) 6872 + .unwrap(); 6873 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "ok"); 6874 + } 6875 + 6876 + #[test] 6877 + fn canvas_clear_rect_runs() { 6878 + let result = eval_with_doc( 6879 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6880 + r#" 6881 + var c = document.getElementById("c"); 6882 + var ctx = c.getContext("2d"); 6883 + ctx.fillStyle = "red"; 6884 + ctx.fillRect(0, 0, 300, 150); 6885 + ctx.clearRect(20, 20, 30, 30); 6886 + "ok" 6887 + "#, 6888 + ) 6889 + .unwrap(); 6890 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "ok"); 6891 + } 6892 + 6893 + #[test] 6894 + fn canvas_fill_rect_with_transform() { 6895 + let result = eval_with_doc( 6896 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6897 + r#" 6898 + var c = document.getElementById("c"); 6899 + var ctx = c.getContext("2d"); 6900 + ctx.fillStyle = "blue"; 6901 + ctx.translate(50, 50); 6902 + ctx.fillRect(0, 0, 20, 20); 6903 + "ok" 6904 + "#, 6905 + ) 6906 + .unwrap(); 6907 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "ok"); 6908 + } 6909 + 6910 + #[test] 6911 + fn canvas_fill_style_rgb_func() { 6912 + let result = eval_with_doc( 6913 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6914 + r#" 6915 + var c = document.getElementById("c"); 6916 + var ctx = c.getContext("2d"); 6917 + ctx.fillStyle = "rgb(128, 64, 32)"; 6918 + ctx.fillStyle 6919 + "#, 6920 + ) 6921 + .unwrap(); 6922 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "#804020"); 6923 + } 6924 + 6925 + #[test] 6926 + fn canvas_fill_style_transparent() { 6927 + let result = eval_with_doc( 6928 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6929 + r#" 6930 + var c = document.getElementById("c"); 6931 + var ctx = c.getContext("2d"); 6932 + ctx.fillStyle = "transparent"; 6933 + ctx.fillStyle 6934 + "#, 6935 + ) 6936 + .unwrap(); 6937 + // transparent = rgba(0,0,0,0) — has alpha < 255 6938 + assert_eq!( 6939 + result.to_js_string(&crate::gc::Gc::new()), 6940 + "rgba(0, 0, 0, 0)" 6941 + ); 6496 6942 } 6497 6943 }
+11
crates/js/src/vm.rs
··· 2771 2771 ) { 2772 2772 return true; 2773 2773 } 2774 + // Check for canvas context property sets (fillStyle, strokeStyle, etc.). 2775 + if crate::dom_bridge::handle_canvas_ctx_set( 2776 + &mut self.gc, 2777 + &mut self.shapes, 2778 + &bridge, 2779 + gc_ref, 2780 + key, 2781 + val, 2782 + ) { 2783 + return true; 2784 + } 2774 2785 // Then check for DOM node wrapper sets. 2775 2786 crate::dom_bridge::handle_dom_set( 2776 2787 &mut self.gc,