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

Implement image resource loading: <img> element support

Add img_loader module to the browser crate that loads images from the DOM:
- Scan DOM for <img src="..."> elements in document order
- Fetch image data via ResourceLoader with URL resolution
- Detect format from magic bytes (PNG: 89504E47, JPEG: FFD8, GIF: GIF8)
- Decode using image crate decoders (PNG, JPEG, GIF)
- Parse width/height attributes with proportional scaling
- Graceful degradation: failed loads store alt text, no crash

Layout integration:
- Add replaced_size field to LayoutBox for replaced elements
- Accept image_sizes map in layout() for intrinsic/attribute dimensions
- Replaced elements use their dimensions instead of child layout

Render integration:
- Add DrawImage variant to PaintCommand
- Nearest-neighbor scaling with RGBA→BGRA conversion
- Alpha compositing for semi-transparent image pixels

37 tests covering format detection, dimension parsing/resolution,
DOM scanning, graceful failure, error display, and PNG decoding.

Implements issue 3mhkt7br4jx25

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

+831 -29
+624
crates/browser/src/img_loader.rs
··· 1 + //! Image resource loading: fetch and decode images referenced by `<img>` elements. 2 + //! 3 + //! After HTML parsing, this module scans the DOM for `<img src="...">` elements, 4 + //! fetches image data via the `ResourceLoader`, detects the image format from 5 + //! magic bytes, and decodes using the appropriate `image` crate decoder. 6 + 7 + use std::collections::HashMap; 8 + 9 + use we_dom::{Document, NodeData, NodeId}; 10 + use we_image::gif::decode_gif; 11 + use we_image::jpeg::decode_jpeg; 12 + use we_image::pixel::{Image, ImageError}; 13 + use we_image::png::decode_png; 14 + use we_url::Url; 15 + 16 + use crate::loader::{LoadError, Resource, ResourceLoader}; 17 + 18 + /// Detected image format from magic bytes. 19 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 20 + pub enum ImageFormat { 21 + Png, 22 + Jpeg, 23 + Gif, 24 + Unknown, 25 + } 26 + 27 + /// Errors that can occur during image loading. 28 + #[derive(Debug)] 29 + pub enum ImgLoadError { 30 + /// A resource failed to load. 31 + Load(LoadError), 32 + /// The fetched resource could not be decoded as an image. 33 + Decode(ImageError), 34 + /// Unknown or unsupported image format. 35 + UnknownFormat { url: String }, 36 + } 37 + 38 + impl std::fmt::Display for ImgLoadError { 39 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 + match self { 41 + Self::Load(e) => write!(f, "image load error: {e}"), 42 + Self::Decode(e) => write!(f, "image decode error: {e}"), 43 + Self::UnknownFormat { url } => write!(f, "unknown image format at {url}"), 44 + } 45 + } 46 + } 47 + 48 + impl From<LoadError> for ImgLoadError { 49 + fn from(e: LoadError) -> Self { 50 + Self::Load(e) 51 + } 52 + } 53 + 54 + impl From<ImageError> for ImgLoadError { 55 + fn from(e: ImageError) -> Self { 56 + Self::Decode(e) 57 + } 58 + } 59 + 60 + /// A successfully or unsuccessfully loaded image resource. 61 + pub struct ImageResource { 62 + /// The decoded RGBA8 image, if loading and decoding succeeded. 63 + pub image: Option<Image>, 64 + /// Display width in CSS pixels (from `width` attribute or intrinsic). 65 + pub display_width: f32, 66 + /// Display height in CSS pixels (from `height` attribute or intrinsic). 67 + pub display_height: f32, 68 + /// Alt text from the `alt` attribute. 69 + pub alt: String, 70 + } 71 + 72 + /// Map from DOM node IDs to their loaded image resources. 73 + pub type ImageStore = HashMap<NodeId, ImageResource>; 74 + 75 + /// Detect image format from the first bytes of data. 76 + pub fn detect_format(data: &[u8]) -> ImageFormat { 77 + // PNG: 89 50 4E 47 0D 0A 1A 0A 78 + if data.len() >= 8 && data[..8] == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] { 79 + return ImageFormat::Png; 80 + } 81 + // JPEG: FF D8 82 + if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 { 83 + return ImageFormat::Jpeg; 84 + } 85 + // GIF: GIF87a or GIF89a 86 + if data.len() >= 6 && &data[..3] == b"GIF" { 87 + return ImageFormat::Gif; 88 + } 89 + ImageFormat::Unknown 90 + } 91 + 92 + /// Collect and load all images referenced by `<img>` elements in the DOM. 93 + /// 94 + /// Scans the DOM in document order for `<img>` elements with a `src` attribute, 95 + /// fetches each image via the `ResourceLoader`, detects the format, and decodes. 96 + /// Failed loads produce an `ImageResource` with `image: None` (graceful degradation). 97 + pub fn collect_images(doc: &Document, loader: &mut ResourceLoader, base_url: &Url) -> ImageStore { 98 + let mut store = ImageStore::new(); 99 + let mut img_nodes = Vec::new(); 100 + collect_img_nodes(doc, doc.root(), &mut img_nodes); 101 + 102 + for node in img_nodes { 103 + let src = match doc.get_attribute(node, "src") { 104 + Some(s) if !s.is_empty() => s.to_string(), 105 + _ => { 106 + // No src — record with alt text only. 107 + let alt = doc.get_attribute(node, "alt").unwrap_or("").to_string(); 108 + store.insert( 109 + node, 110 + ImageResource { 111 + image: None, 112 + display_width: 0.0, 113 + display_height: 0.0, 114 + alt, 115 + }, 116 + ); 117 + continue; 118 + } 119 + }; 120 + 121 + let alt = doc.get_attribute(node, "alt").unwrap_or("").to_string(); 122 + let attr_width = parse_dimension_attr(doc.get_attribute(node, "width")); 123 + let attr_height = parse_dimension_attr(doc.get_attribute(node, "height")); 124 + 125 + match fetch_and_decode(loader, &src, base_url) { 126 + Ok(image) => { 127 + let intrinsic_w = image.width as f32; 128 + let intrinsic_h = image.height as f32; 129 + let (dw, dh) = 130 + resolve_dimensions(attr_width, attr_height, intrinsic_w, intrinsic_h); 131 + store.insert( 132 + node, 133 + ImageResource { 134 + image: Some(image), 135 + display_width: dw, 136 + display_height: dh, 137 + alt, 138 + }, 139 + ); 140 + } 141 + Err(_) => { 142 + // Graceful degradation: store alt text, no image. 143 + let (dw, dh) = match (attr_width, attr_height) { 144 + (Some(w), Some(h)) => (w, h), 145 + (Some(w), None) => (w, 0.0), 146 + (None, Some(h)) => (0.0, h), 147 + (None, None) => (0.0, 0.0), 148 + }; 149 + store.insert( 150 + node, 151 + ImageResource { 152 + image: None, 153 + display_width: dw, 154 + display_height: dh, 155 + alt, 156 + }, 157 + ); 158 + } 159 + } 160 + } 161 + 162 + store 163 + } 164 + 165 + /// Walk the DOM in document order and collect `<img>` element nodes. 166 + fn collect_img_nodes(doc: &Document, node: NodeId, result: &mut Vec<NodeId>) { 167 + if let NodeData::Element { tag_name, .. } = doc.node_data(node) { 168 + if tag_name.eq_ignore_ascii_case("img") { 169 + result.push(node); 170 + } 171 + } 172 + for child in doc.children(node) { 173 + collect_img_nodes(doc, child, result); 174 + } 175 + } 176 + 177 + /// Parse a dimension attribute value (e.g., `width="200"`) to f32. 178 + fn parse_dimension_attr(value: Option<&str>) -> Option<f32> { 179 + value.and_then(|v| { 180 + let v = v.trim(); 181 + // Strip trailing "px" if present. 182 + let v = v.strip_suffix("px").unwrap_or(v); 183 + v.parse::<f32>().ok().filter(|&n| n > 0.0) 184 + }) 185 + } 186 + 187 + /// Resolve display dimensions from attribute values and intrinsic image size. 188 + /// 189 + /// If both attributes are set, use them directly. 190 + /// If only one is set, scale the other proportionally. 191 + /// If neither is set, use intrinsic dimensions. 192 + fn resolve_dimensions( 193 + attr_w: Option<f32>, 194 + attr_h: Option<f32>, 195 + intrinsic_w: f32, 196 + intrinsic_h: f32, 197 + ) -> (f32, f32) { 198 + match (attr_w, attr_h) { 199 + (Some(w), Some(h)) => (w, h), 200 + (Some(w), None) => { 201 + if intrinsic_w > 0.0 { 202 + (w, w * intrinsic_h / intrinsic_w) 203 + } else { 204 + (w, intrinsic_h) 205 + } 206 + } 207 + (None, Some(h)) => { 208 + if intrinsic_h > 0.0 { 209 + (h * intrinsic_w / intrinsic_h, h) 210 + } else { 211 + (intrinsic_w, h) 212 + } 213 + } 214 + (None, None) => (intrinsic_w, intrinsic_h), 215 + } 216 + } 217 + 218 + /// Fetch image data from a URL and decode it. 219 + fn fetch_and_decode( 220 + loader: &mut ResourceLoader, 221 + src: &str, 222 + base_url: &Url, 223 + ) -> Result<Image, ImgLoadError> { 224 + let resource = loader.fetch_url(src, Some(base_url))?; 225 + 226 + let (data, url_str) = match resource { 227 + Resource::Image { data, url, .. } => (data, url.to_string()), 228 + Resource::Other { data, url, .. } => (data, url.to_string()), 229 + Resource::Html { text, .. } => (text.into_bytes(), src.to_string()), 230 + Resource::Css { text, .. } => (text.into_bytes(), src.to_string()), 231 + }; 232 + 233 + decode_image_data(&data, &url_str) 234 + } 235 + 236 + /// Decode raw bytes into an Image, detecting format from magic bytes. 237 + fn decode_image_data(data: &[u8], url: &str) -> Result<Image, ImgLoadError> { 238 + match detect_format(data) { 239 + ImageFormat::Png => Ok(decode_png(data)?), 240 + ImageFormat::Jpeg => Ok(decode_jpeg(data)?), 241 + ImageFormat::Gif => Ok(decode_gif(data)?), 242 + ImageFormat::Unknown => Err(ImgLoadError::UnknownFormat { 243 + url: url.to_string(), 244 + }), 245 + } 246 + } 247 + 248 + // --------------------------------------------------------------------------- 249 + // Tests 250 + // --------------------------------------------------------------------------- 251 + 252 + #[cfg(test)] 253 + mod tests { 254 + use super::*; 255 + 256 + // ----------------------------------------------------------------------- 257 + // detect_format 258 + // ----------------------------------------------------------------------- 259 + 260 + #[test] 261 + fn detect_png() { 262 + let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]; 263 + assert_eq!(detect_format(&data), ImageFormat::Png); 264 + } 265 + 266 + #[test] 267 + fn detect_jpeg() { 268 + let data = [0xFF, 0xD8, 0xFF, 0xE0]; 269 + assert_eq!(detect_format(&data), ImageFormat::Jpeg); 270 + } 271 + 272 + #[test] 273 + fn detect_gif87a() { 274 + assert_eq!(detect_format(b"GIF87a..."), ImageFormat::Gif); 275 + } 276 + 277 + #[test] 278 + fn detect_gif89a() { 279 + assert_eq!(detect_format(b"GIF89a..."), ImageFormat::Gif); 280 + } 281 + 282 + #[test] 283 + fn detect_unknown_empty() { 284 + assert_eq!(detect_format(&[]), ImageFormat::Unknown); 285 + } 286 + 287 + #[test] 288 + fn detect_unknown_random() { 289 + assert_eq!(detect_format(&[0x00, 0x01, 0x02]), ImageFormat::Unknown); 290 + } 291 + 292 + #[test] 293 + fn detect_short_data() { 294 + assert_eq!(detect_format(&[0xFF]), ImageFormat::Unknown); 295 + } 296 + 297 + // ----------------------------------------------------------------------- 298 + // parse_dimension_attr 299 + // ----------------------------------------------------------------------- 300 + 301 + #[test] 302 + fn parse_dimension_integer() { 303 + assert_eq!(parse_dimension_attr(Some("200")), Some(200.0)); 304 + } 305 + 306 + #[test] 307 + fn parse_dimension_with_px_suffix() { 308 + assert_eq!(parse_dimension_attr(Some("100px")), Some(100.0)); 309 + } 310 + 311 + #[test] 312 + fn parse_dimension_float() { 313 + assert_eq!(parse_dimension_attr(Some("50.5")), Some(50.5)); 314 + } 315 + 316 + #[test] 317 + fn parse_dimension_whitespace() { 318 + assert_eq!(parse_dimension_attr(Some(" 300 ")), Some(300.0)); 319 + } 320 + 321 + #[test] 322 + fn parse_dimension_zero() { 323 + assert_eq!(parse_dimension_attr(Some("0")), None); 324 + } 325 + 326 + #[test] 327 + fn parse_dimension_negative() { 328 + assert_eq!(parse_dimension_attr(Some("-10")), None); 329 + } 330 + 331 + #[test] 332 + fn parse_dimension_invalid() { 333 + assert_eq!(parse_dimension_attr(Some("abc")), None); 334 + } 335 + 336 + #[test] 337 + fn parse_dimension_none() { 338 + assert_eq!(parse_dimension_attr(None), None); 339 + } 340 + 341 + #[test] 342 + fn parse_dimension_empty() { 343 + assert_eq!(parse_dimension_attr(Some("")), None); 344 + } 345 + 346 + // ----------------------------------------------------------------------- 347 + // resolve_dimensions 348 + // ----------------------------------------------------------------------- 349 + 350 + #[test] 351 + fn resolve_both_attrs() { 352 + let (w, h) = resolve_dimensions(Some(400.0), Some(300.0), 800.0, 600.0); 353 + assert_eq!(w, 400.0); 354 + assert_eq!(h, 300.0); 355 + } 356 + 357 + #[test] 358 + fn resolve_width_only_proportional() { 359 + let (w, h) = resolve_dimensions(Some(400.0), None, 800.0, 600.0); 360 + assert_eq!(w, 400.0); 361 + assert_eq!(h, 300.0); // 400 * 600/800 362 + } 363 + 364 + #[test] 365 + fn resolve_height_only_proportional() { 366 + let (w, h) = resolve_dimensions(None, Some(300.0), 800.0, 600.0); 367 + assert_eq!(w, 400.0); // 300 * 800/600 368 + assert_eq!(h, 300.0); 369 + } 370 + 371 + #[test] 372 + fn resolve_neither_uses_intrinsic() { 373 + let (w, h) = resolve_dimensions(None, None, 1024.0, 768.0); 374 + assert_eq!(w, 1024.0); 375 + assert_eq!(h, 768.0); 376 + } 377 + 378 + #[test] 379 + fn resolve_width_only_zero_intrinsic() { 380 + let (w, h) = resolve_dimensions(Some(400.0), None, 0.0, 600.0); 381 + assert_eq!(w, 400.0); 382 + assert_eq!(h, 600.0); // can't scale, use intrinsic height 383 + } 384 + 385 + #[test] 386 + fn resolve_height_only_zero_intrinsic() { 387 + let (w, h) = resolve_dimensions(None, Some(300.0), 800.0, 0.0); 388 + assert_eq!(w, 800.0); // can't scale, use intrinsic width 389 + assert_eq!(h, 300.0); 390 + } 391 + 392 + // ----------------------------------------------------------------------- 393 + // collect_img_nodes 394 + // ----------------------------------------------------------------------- 395 + 396 + #[test] 397 + fn collects_img_elements() { 398 + let mut doc = Document::new(); 399 + let root = doc.root(); 400 + 401 + let html = doc.create_element("html"); 402 + doc.append_child(root, html); 403 + 404 + let body = doc.create_element("body"); 405 + doc.append_child(html, body); 406 + 407 + let img = doc.create_element("img"); 408 + doc.set_attribute(img, "src", "photo.png"); 409 + doc.append_child(body, img); 410 + 411 + let mut nodes = Vec::new(); 412 + collect_img_nodes(&doc, doc.root(), &mut nodes); 413 + assert_eq!(nodes.len(), 1); 414 + assert_eq!(doc.tag_name(nodes[0]), Some("img")); 415 + } 416 + 417 + #[test] 418 + fn collects_multiple_imgs() { 419 + let mut doc = Document::new(); 420 + let root = doc.root(); 421 + 422 + let body = doc.create_element("body"); 423 + doc.append_child(root, body); 424 + 425 + let img1 = doc.create_element("img"); 426 + doc.set_attribute(img1, "src", "a.png"); 427 + doc.append_child(body, img1); 428 + 429 + let img2 = doc.create_element("img"); 430 + doc.set_attribute(img2, "src", "b.jpg"); 431 + doc.append_child(body, img2); 432 + 433 + let mut nodes = Vec::new(); 434 + collect_img_nodes(&doc, doc.root(), &mut nodes); 435 + assert_eq!(nodes.len(), 2); 436 + } 437 + 438 + #[test] 439 + fn ignores_non_img_elements() { 440 + let mut doc = Document::new(); 441 + let root = doc.root(); 442 + 443 + let p = doc.create_element("p"); 444 + doc.append_child(root, p); 445 + 446 + let div = doc.create_element("div"); 447 + doc.append_child(root, div); 448 + 449 + let mut nodes = Vec::new(); 450 + collect_img_nodes(&doc, doc.root(), &mut nodes); 451 + assert!(nodes.is_empty()); 452 + } 453 + 454 + // ----------------------------------------------------------------------- 455 + // collect_images — integration 456 + // ----------------------------------------------------------------------- 457 + 458 + #[test] 459 + fn collect_images_no_src() { 460 + let mut doc = Document::new(); 461 + let root = doc.root(); 462 + 463 + let img = doc.create_element("img"); 464 + doc.set_attribute(img, "alt", "missing"); 465 + doc.append_child(root, img); 466 + 467 + let mut loader = ResourceLoader::new(); 468 + let base = Url::parse("http://example.com/").unwrap(); 469 + 470 + let store = collect_images(&doc, &mut loader, &base); 471 + assert_eq!(store.len(), 1); 472 + let res = store.get(&img).unwrap(); 473 + assert!(res.image.is_none()); 474 + assert_eq!(res.alt, "missing"); 475 + } 476 + 477 + #[test] 478 + fn collect_images_empty_src() { 479 + let mut doc = Document::new(); 480 + let root = doc.root(); 481 + 482 + let img = doc.create_element("img"); 483 + doc.set_attribute(img, "src", ""); 484 + doc.set_attribute(img, "alt", "empty src"); 485 + doc.append_child(root, img); 486 + 487 + let mut loader = ResourceLoader::new(); 488 + let base = Url::parse("http://example.com/").unwrap(); 489 + 490 + let store = collect_images(&doc, &mut loader, &base); 491 + let res = store.get(&img).unwrap(); 492 + assert!(res.image.is_none()); 493 + assert_eq!(res.alt, "empty src"); 494 + } 495 + 496 + #[test] 497 + fn collect_images_failed_fetch_graceful() { 498 + let mut doc = Document::new(); 499 + let root = doc.root(); 500 + 501 + let img = doc.create_element("img"); 502 + doc.set_attribute(img, "src", "http://nonexistent.test/photo.png"); 503 + doc.set_attribute(img, "alt", "Photo"); 504 + doc.set_attribute(img, "width", "200"); 505 + doc.set_attribute(img, "height", "150"); 506 + doc.append_child(root, img); 507 + 508 + let mut loader = ResourceLoader::new(); 509 + let base = Url::parse("http://example.com/").unwrap(); 510 + 511 + let store = collect_images(&doc, &mut loader, &base); 512 + let res = store.get(&img).unwrap(); 513 + assert!(res.image.is_none()); 514 + assert_eq!(res.alt, "Photo"); 515 + assert_eq!(res.display_width, 200.0); 516 + assert_eq!(res.display_height, 150.0); 517 + } 518 + 519 + #[test] 520 + fn collect_images_no_alt() { 521 + let mut doc = Document::new(); 522 + let root = doc.root(); 523 + 524 + let img = doc.create_element("img"); 525 + doc.set_attribute(img, "src", "http://nonexistent.test/x.png"); 526 + doc.append_child(root, img); 527 + 528 + let mut loader = ResourceLoader::new(); 529 + let base = Url::parse("http://example.com/").unwrap(); 530 + 531 + let store = collect_images(&doc, &mut loader, &base); 532 + let res = store.get(&img).unwrap(); 533 + assert_eq!(res.alt, ""); 534 + } 535 + 536 + // ----------------------------------------------------------------------- 537 + // ImgLoadError display 538 + // ----------------------------------------------------------------------- 539 + 540 + #[test] 541 + fn error_display_unknown_format() { 542 + let e = ImgLoadError::UnknownFormat { 543 + url: "test.bin".to_string(), 544 + }; 545 + assert_eq!(e.to_string(), "unknown image format at test.bin"); 546 + } 547 + 548 + #[test] 549 + fn error_display_load() { 550 + let e = ImgLoadError::Load(LoadError::InvalidUrl("bad".to_string())); 551 + assert!(e.to_string().contains("image load error")); 552 + } 553 + 554 + #[test] 555 + fn error_display_decode() { 556 + let e = ImgLoadError::Decode(ImageError::Decode("corrupt".to_string())); 557 + assert!(e.to_string().contains("image decode error")); 558 + } 559 + 560 + // ----------------------------------------------------------------------- 561 + // decode_image_data — unit tests with minimal valid images 562 + // ----------------------------------------------------------------------- 563 + 564 + #[test] 565 + fn decode_unknown_format_error() { 566 + let result = decode_image_data(&[0x00, 0x01, 0x02], "mystery.bin"); 567 + assert!(result.is_err()); 568 + assert!(matches!(result, Err(ImgLoadError::UnknownFormat { .. }))); 569 + } 570 + 571 + #[test] 572 + fn decode_truncated_png_error() { 573 + // Valid PNG header but no actual image data. 574 + let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; 575 + let result = decode_image_data(&data, "broken.png"); 576 + assert!(result.is_err()); 577 + } 578 + 579 + #[test] 580 + fn decode_truncated_jpeg_error() { 581 + let data = [0xFF, 0xD8, 0xFF, 0xE0]; 582 + let result = decode_image_data(&data, "broken.jpg"); 583 + assert!(result.is_err()); 584 + } 585 + 586 + #[test] 587 + fn decode_truncated_gif_error() { 588 + let result = decode_image_data(b"GIF89a", "broken.gif"); 589 + assert!(result.is_err()); 590 + } 591 + 592 + // ----------------------------------------------------------------------- 593 + // decode_image_data — valid minimal images 594 + // ----------------------------------------------------------------------- 595 + 596 + #[test] 597 + fn decode_valid_png() { 598 + // Minimal 1x1 red PNG (RGB, bit depth 8). 599 + let data: &[u8] = &[ 600 + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 601 + 0x00, 0x00, 0x00, 0x0D, // IHDR length 602 + 0x49, 0x48, 0x44, 0x52, // IHDR 603 + 0x00, 0x00, 0x00, 0x01, // width: 1 604 + 0x00, 0x00, 0x00, 0x01, // height: 1 605 + 0x08, 0x02, // bit depth: 8, color type: RGB 606 + 0x00, 0x00, 0x00, // compression, filter, interlace 607 + 0x90, 0x77, 0x53, 0xDE, // CRC 608 + 0x00, 0x00, 0x00, 0x0C, // IDAT length 609 + 0x49, 0x44, 0x41, 0x54, // IDAT 610 + 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, // zlib data 611 + 0x03, 0x01, 0x01, 0x00, // Adler32 612 + 0xC9, 0xFE, 0x92, 0xEF, // CRC 613 + 0x00, 0x00, 0x00, 0x00, // IEND length 614 + 0x49, 0x45, 0x4E, 0x44, // IEND 615 + 0xAE, 0x42, 0x60, 0x82, // CRC 616 + ]; 617 + let result = decode_image_data(data, "red.png"); 618 + assert!(result.is_ok(), "PNG decode failed: {:?}", result.err()); 619 + let img = result.unwrap(); 620 + assert_eq!(img.width, 1); 621 + assert_eq!(img.height, 1); 622 + assert_eq!(img.data.len(), 4); // 1x1 RGBA8 623 + } 624 + }
+1
crates/browser/src/lib.rs
··· 1 1 //! Event loop, resource loading, navigation, UI chrome. 2 2 3 3 pub mod css_loader; 4 + pub mod img_loader; 4 5 pub mod loader;
+10 -2
crates/browser/src/main.rs
··· 1 1 use std::cell::RefCell; 2 + use std::collections::HashMap; 2 3 3 4 use we_html::parse_html; 4 5 use we_layout::layout; ··· 60 61 }; 61 62 62 63 // Layout using styled tree (CSS-driven). 63 - let tree = layout(&styled, &doc, width as f32, height as f32, font); 64 + let tree = layout( 65 + &styled, 66 + &doc, 67 + width as f32, 68 + height as f32, 69 + font, 70 + &HashMap::new(), 71 + ); 64 72 65 73 let mut renderer = Renderer::new(width, height); 66 - renderer.paint(&tree, font); 74 + renderer.paint(&tree, font, &HashMap::new()); 67 75 68 76 // Copy rendered pixels into the bitmap context's buffer. 69 77 let src = renderer.pixels();
+43 -18
crates/layout/src/lib.rs
··· 3 3 //! Builds a layout tree from a styled tree (DOM + computed styles) and positions 4 4 //! block-level elements vertically with proper inline formatting context. 5 5 6 + use std::collections::HashMap; 7 + 6 8 use we_css::values::Color; 7 9 use we_dom::{Document, NodeData, NodeId}; 8 10 use we_style::computed::{ ··· 84 86 pub text_align: TextAlign, 85 87 /// Computed line height in px. 86 88 pub line_height: f32, 89 + /// For replaced elements (e.g., `<img>`): content dimensions (width, height). 90 + pub replaced_size: Option<(f32, f32)>, 87 91 } 88 92 89 93 impl LayoutBox { ··· 114 118 ], 115 119 text_align: style.text_align, 116 120 line_height: style.line_height, 121 + replaced_size: None, 117 122 } 118 123 } 119 124 ··· 181 186 // Build layout tree from styled tree 182 187 // --------------------------------------------------------------------------- 183 188 184 - fn build_box(styled: &StyledNode, doc: &Document) -> Option<LayoutBox> { 189 + fn build_box( 190 + styled: &StyledNode, 191 + doc: &Document, 192 + image_sizes: &HashMap<NodeId, (f32, f32)>, 193 + ) -> Option<LayoutBox> { 185 194 let node = styled.node; 186 195 let style = &styled.style; 187 196 ··· 189 198 NodeData::Document => { 190 199 let mut children = Vec::new(); 191 200 for child in &styled.children { 192 - if let Some(child_box) = build_box(child, doc) { 201 + if let Some(child_box) = build_box(child, doc, image_sizes) { 193 202 children.push(child_box); 194 203 } 195 204 } ··· 245 254 246 255 let mut children = Vec::new(); 247 256 for child in &styled.children { 248 - if let Some(child_box) = build_box(child, doc) { 257 + if let Some(child_box) = build_box(child, doc, image_sizes) { 249 258 children.push(child_box); 250 259 } 251 260 } ··· 265 274 b.padding = padding; 266 275 b.border = border; 267 276 b.children = children; 277 + 278 + // Check for replaced element (e.g., <img>). 279 + if let Some(&(w, h)) = image_sizes.get(&node) { 280 + b.replaced_size = Some((w, h)); 281 + } 282 + 268 283 Some(b) 269 284 } 270 285 NodeData::Text { data } => { ··· 375 390 b.rect.x = content_x; 376 391 b.rect.y = content_y; 377 392 b.rect.width = content_width; 393 + 394 + // Replaced elements (e.g., <img>) have intrinsic dimensions. 395 + if let Some((rw, rh)) = b.replaced_size { 396 + // Use CSS width/height if specified, otherwise use replaced dimensions. 397 + // Content width is the minimum of replaced width and available width. 398 + b.rect.width = rw.min(content_width); 399 + b.rect.height = rh; 400 + return; 401 + } 378 402 379 403 match &b.box_type { 380 404 BoxType::Block(_) | BoxType::Anonymous => { ··· 712 736 viewport_width: f32, 713 737 _viewport_height: f32, 714 738 font: &Font, 739 + image_sizes: &HashMap<NodeId, (f32, f32)>, 715 740 ) -> LayoutTree { 716 - let mut root = match build_box(styled_root, doc) { 741 + let mut root = match build_box(styled_root, doc, image_sizes) { 717 742 Some(b) => b, 718 743 None => { 719 744 return LayoutTree { ··· 758 783 let font = test_font(); 759 784 let sheets = extract_stylesheets(doc); 760 785 let styled = resolve_styles(doc, &sheets).unwrap(); 761 - layout(&styled, doc, 800.0, 600.0, &font) 786 + layout(&styled, doc, 800.0, 600.0, &font, &HashMap::new()) 762 787 } 763 788 764 789 #[test] ··· 768 793 let sheets = extract_stylesheets(&doc); 769 794 let styled = resolve_styles(&doc, &sheets); 770 795 if let Some(styled) = styled { 771 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 796 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 772 797 assert_eq!(tree.width, 800.0); 773 798 } 774 799 } ··· 926 951 let font = test_font(); 927 952 let sheets = extract_stylesheets(&doc); 928 953 let styled = resolve_styles(&doc, &sheets).unwrap(); 929 - let tree = layout(&styled, &doc, 100.0, 600.0, &font); 954 + let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 930 955 let body_box = &tree.root.children[0]; 931 956 let p_box = &body_box.children[0]; 932 957 ··· 1092 1117 let font = test_font(); 1093 1118 let sheets = extract_stylesheets(&doc); 1094 1119 let styled = resolve_styles(&doc, &sheets).unwrap(); 1095 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1120 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1096 1121 let body_box = &tree.root.children[0]; 1097 1122 1098 1123 assert_eq!(body_box.rect.width, 784.0); ··· 1167 1192 let font = test_font(); 1168 1193 let sheets = extract_stylesheets(&doc); 1169 1194 let styled = resolve_styles(&doc, &sheets).unwrap(); 1170 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1195 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1171 1196 1172 1197 let body_box = &tree.root.children[0]; 1173 1198 let first = &body_box.children[0]; ··· 1193 1218 let font = test_font(); 1194 1219 let sheets = extract_stylesheets(&doc); 1195 1220 let styled = resolve_styles(&doc, &sheets).unwrap(); 1196 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1221 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1197 1222 1198 1223 let body_box = &tree.root.children[0]; 1199 1224 let div_box = &body_box.children[0]; ··· 1213 1238 let font = test_font(); 1214 1239 let sheets = extract_stylesheets(&doc); 1215 1240 let styled = resolve_styles(&doc, &sheets).unwrap(); 1216 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1241 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1217 1242 1218 1243 let body_box = &tree.root.children[0]; 1219 1244 let p_box = &body_box.children[0]; ··· 1235 1260 let font = test_font(); 1236 1261 let sheets = extract_stylesheets(&doc); 1237 1262 let styled = resolve_styles(&doc, &sheets).unwrap(); 1238 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1263 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1239 1264 1240 1265 let body_box = &tree.root.children[0]; 1241 1266 let p_box = &body_box.children[0]; ··· 1261 1286 let font = test_font(); 1262 1287 let sheets = extract_stylesheets(&doc); 1263 1288 let styled = resolve_styles(&doc, &sheets).unwrap(); 1264 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1289 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1265 1290 1266 1291 let body_box = &tree.root.children[0]; 1267 1292 let p_box = &body_box.children[0]; ··· 1288 1313 let font = test_font(); 1289 1314 let sheets = extract_stylesheets(&doc); 1290 1315 let styled = resolve_styles(&doc, &sheets).unwrap(); 1291 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1316 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1292 1317 1293 1318 let body_box = &tree.root.children[0]; 1294 1319 let p_box = &body_box.children[0]; ··· 1315 1340 let font = test_font(); 1316 1341 let sheets = extract_stylesheets(&doc); 1317 1342 let styled = resolve_styles(&doc, &sheets).unwrap(); 1318 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1343 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1319 1344 1320 1345 let body_box = &tree.root.children[0]; 1321 1346 let p_box = &body_box.children[0]; ··· 1342 1367 let font = test_font(); 1343 1368 let sheets = extract_stylesheets(&doc); 1344 1369 let styled = resolve_styles(&doc, &sheets).unwrap(); 1345 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1370 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1346 1371 1347 1372 let body_box = &tree.root.children[0]; 1348 1373 let p_box = &body_box.children[0]; ··· 1375 1400 let font = test_font(); 1376 1401 let sheets = extract_stylesheets(&doc); 1377 1402 let styled = resolve_styles(&doc, &sheets).unwrap(); 1378 - let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1403 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1379 1404 1380 1405 let body_box = &tree.root.children[0]; 1381 1406 let h1_box = &body_box.children[0]; ··· 1399 1424 let sheets = extract_stylesheets(&doc); 1400 1425 let styled = resolve_styles(&doc, &sheets).unwrap(); 1401 1426 // Narrow viewport to force wrapping. 1402 - let tree = layout(&styled, &doc, 100.0, 600.0, &font); 1427 + let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 1403 1428 1404 1429 let body_box = &tree.root.children[0]; 1405 1430 let p_box = &body_box.children[0];
+153 -9
crates/render/src/lib.rs
··· 3 3 //! Walks a layout tree, generates paint commands, and rasterizes them 4 4 //! into a BGRA pixel buffer suitable for display via CoreGraphics. 5 5 6 + use std::collections::HashMap; 7 + 6 8 use we_css::values::Color; 7 - use we_layout::{LayoutBox, LayoutTree, TextLine}; 9 + use we_dom::NodeId; 10 + use we_image::pixel::Image; 11 + use we_layout::{BoxType, LayoutBox, LayoutTree, TextLine}; 8 12 use we_style::computed::{BorderStyle, TextDecoration}; 9 13 use we_text::font::Font; 10 14 ··· 25 29 font_size: f32, 26 30 color: Color, 27 31 }, 32 + /// Draw an image at a position with given display dimensions. 33 + DrawImage { 34 + x: f32, 35 + y: f32, 36 + width: f32, 37 + height: f32, 38 + node_id: NodeId, 39 + }, 28 40 } 29 41 30 42 /// A flat list of paint commands in painter's order. ··· 43 55 fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { 44 56 paint_background(layout_box, list); 45 57 paint_borders(layout_box, list); 58 + 59 + // Emit image paint command for replaced elements. 60 + if let Some((rw, rh)) = layout_box.replaced_size { 61 + if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { 62 + list.push(PaintCommand::DrawImage { 63 + x: layout_box.rect.x, 64 + y: layout_box.rect.y, 65 + width: rw, 66 + height: rh, 67 + node_id, 68 + }); 69 + } 70 + } 71 + 46 72 paint_text(layout_box, list); 47 73 48 74 // Recurse into children. 49 75 for child in &layout_box.children { 50 76 paint_box(child, list); 77 + } 78 + } 79 + 80 + /// Extract the NodeId from a BoxType, if it has one. 81 + fn node_id_from_box_type(box_type: &BoxType) -> Option<NodeId> { 82 + match box_type { 83 + BoxType::Block(id) | BoxType::Inline(id) => Some(*id), 84 + BoxType::TextRun { node, .. } => Some(*node), 85 + BoxType::Anonymous => None, 51 86 } 52 87 } 53 88 ··· 196 231 } 197 232 198 233 /// Paint a layout tree into the pixel buffer. 199 - pub fn paint(&mut self, layout_tree: &LayoutTree, font: &Font) { 234 + pub fn paint( 235 + &mut self, 236 + layout_tree: &LayoutTree, 237 + font: &Font, 238 + images: &HashMap<NodeId, &Image>, 239 + ) { 200 240 let display_list = build_display_list(layout_tree); 201 241 for cmd in &display_list { 202 242 match cmd { ··· 215 255 color, 216 256 } => { 217 257 self.draw_text_line(line, *font_size, *color, font); 258 + } 259 + PaintCommand::DrawImage { 260 + x, 261 + y, 262 + width, 263 + height, 264 + node_id, 265 + } => { 266 + if let Some(image) = images.get(node_id) { 267 + self.draw_image(*x, *y, *width, *height, image); 268 + } 218 269 } 219 270 } 220 271 } ··· 340 391 } 341 392 } 342 393 394 + /// Draw an RGBA8 image scaled to the given display dimensions. 395 + /// 396 + /// Uses nearest-neighbor sampling for scaling. The source image is in RGBA8 397 + /// format; the buffer is BGRA. Alpha compositing is performed for semi-transparent pixels. 398 + fn draw_image(&mut self, x: f32, y: f32, width: f32, height: f32, image: &Image) { 399 + if image.width == 0 || image.height == 0 || width <= 0.0 || height <= 0.0 { 400 + return; 401 + } 402 + 403 + let dst_x0 = (x as i32).max(0) as u32; 404 + let dst_y0 = (y as i32).max(0) as u32; 405 + let dst_x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32; 406 + let dst_y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32; 407 + 408 + let scale_x = image.width as f32 / width; 409 + let scale_y = image.height as f32 / height; 410 + 411 + for dst_y in dst_y0..dst_y1 { 412 + let src_y = ((dst_y as f32 - y) * scale_y) as u32; 413 + let src_y = src_y.min(image.height - 1); 414 + 415 + for dst_x in dst_x0..dst_x1 { 416 + let src_x = ((dst_x as f32 - x) * scale_x) as u32; 417 + let src_x = src_x.min(image.width - 1); 418 + 419 + let src_offset = ((src_y * image.width + src_x) * 4) as usize; 420 + let r = image.data[src_offset] as u32; 421 + let g = image.data[src_offset + 1] as u32; 422 + let b = image.data[src_offset + 2] as u32; 423 + let a = image.data[src_offset + 3] as u32; 424 + 425 + if a == 0 { 426 + continue; 427 + } 428 + 429 + let dst_offset = ((dst_y * self.width + dst_x) * 4) as usize; 430 + 431 + if a == 255 { 432 + // Fully opaque — direct write (RGBA → BGRA). 433 + self.buffer[dst_offset] = b as u8; 434 + self.buffer[dst_offset + 1] = g as u8; 435 + self.buffer[dst_offset + 2] = r as u8; 436 + self.buffer[dst_offset + 3] = 255; 437 + } else { 438 + // Alpha blend. 439 + let inv_a = 255 - a; 440 + let dst_b = self.buffer[dst_offset] as u32; 441 + let dst_g = self.buffer[dst_offset + 1] as u32; 442 + let dst_r = self.buffer[dst_offset + 2] as u32; 443 + self.buffer[dst_offset] = ((b * a + dst_b * inv_a) / 255) as u8; 444 + self.buffer[dst_offset + 1] = ((g * a + dst_g * inv_a) / 255) as u8; 445 + self.buffer[dst_offset + 2] = ((r * a + dst_r * inv_a) / 255) as u8; 446 + self.buffer[dst_offset + 3] = 255; 447 + } 448 + } 449 + } 450 + } 451 + 343 452 /// Set a single pixel to the given color (no blending). 344 453 fn set_pixel(&mut self, x: u32, y: u32, color: Color) { 345 454 if x >= self.width || y >= self.height { ··· 378 487 let font = test_font(); 379 488 let sheets = extract_stylesheets(doc); 380 489 let styled = resolve_styles(doc, &sheets).unwrap(); 381 - we_layout::layout(&styled, doc, 800.0, 600.0, &font) 490 + we_layout::layout( 491 + &styled, 492 + doc, 493 + 800.0, 494 + 600.0, 495 + &font, 496 + &std::collections::HashMap::new(), 497 + ) 382 498 } 383 499 384 500 #[test] ··· 433 549 let sheets = extract_stylesheets(&doc); 434 550 let styled = resolve_styles(&doc, &sheets); 435 551 if let Some(styled) = styled { 436 - let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); 552 + let tree = we_layout::layout( 553 + &styled, 554 + &doc, 555 + 800.0, 556 + 600.0, 557 + &font, 558 + &std::collections::HashMap::new(), 559 + ); 437 560 let list = build_display_list(&tree); 438 561 assert!(list.len() <= 1); 439 562 } ··· 478 601 let font = test_font(); 479 602 let tree = layout_doc(&doc); 480 603 let mut renderer = Renderer::new(800, 600); 481 - renderer.paint(&tree, &font); 604 + renderer.paint(&tree, &font, &HashMap::new()); 482 605 483 606 let pixels = renderer.pixels(); 484 607 ··· 567 690 let font = test_font(); 568 691 let tree = layout_doc(&doc); 569 692 let mut renderer = Renderer::new(800, 600); 570 - renderer.paint(&tree, &font); 693 + renderer.paint(&tree, &font, &HashMap::new()); 571 694 572 695 let pixels = renderer.pixels(); 573 696 // Find pixels that are not pure white and not pure black. ··· 600 723 let font = test_font(); 601 724 let sheets = extract_stylesheets(&doc); 602 725 let styled = resolve_styles(&doc, &sheets).unwrap(); 603 - let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); 726 + let tree = we_layout::layout( 727 + &styled, 728 + &doc, 729 + 800.0, 730 + 600.0, 731 + &font, 732 + &std::collections::HashMap::new(), 733 + ); 604 734 605 735 let list = build_display_list(&tree); 606 736 let text_colors: Vec<&Color> = list ··· 627 757 let font = test_font(); 628 758 let sheets = extract_stylesheets(&doc); 629 759 let styled = resolve_styles(&doc, &sheets).unwrap(); 630 - let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); 760 + let tree = we_layout::layout( 761 + &styled, 762 + &doc, 763 + 800.0, 764 + 600.0, 765 + &font, 766 + &std::collections::HashMap::new(), 767 + ); 631 768 632 769 let list = build_display_list(&tree); 633 770 let fill_colors: Vec<&Color> = list ··· 653 790 let font = test_font(); 654 791 let sheets = extract_stylesheets(&doc); 655 792 let styled = resolve_styles(&doc, &sheets).unwrap(); 656 - let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); 793 + let tree = we_layout::layout( 794 + &styled, 795 + &doc, 796 + 800.0, 797 + 600.0, 798 + &font, 799 + &std::collections::HashMap::new(), 800 + ); 657 801 658 802 let list = build_display_list(&tree); 659 803 let red_fills: Vec<_> = list