From d07e73fab6a9d87ad7542e003e89966e17d0b332 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 9 Apr 2026 00:51:31 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(htmlcss):=20add=20image=20support=20?= =?UTF-8?q?=E2=80=94=20,=20background-image:=20url(),=20ImageProvider?= =?UTF-8?q?=20trait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a host-agnostic image loading architecture for the htmlcss renderer, inspired by Chromium's StyleImage / ImageResourceContent pattern. The module never blocks on image loads — missing images render as placeholders. **Core abstractions (Chromium-inspired, self-contained in htmlcss):** - `ImageProvider` trait — host implements get(url) → Option<&Image> - `NoImages` — zero-cost null provider for image-free rendering - `PreloadedImages` — HashMap-backed provider for pre-resolved flow - `StyleImage` enum — polymorphic CSS image (Url | gradient variants) - `BackgroundLayer` refactored to Solid | Image(StyleImage) - `ObjectFit` enum delegating to math2::BoxFit::calculate_transform ** element support:** - Detected via detect_widget, goes through full Stylo cascade - CSS width/height takes priority over HTML attrs over intrinsic size - aspect_ratio set on Taffy style for proportional auto-sizing - Painted with object-fit via BoxFit transform + content-box clip **background-image: url() support:** - Extracts from both ComputedUrl::Valid and ComputedUrl::Invalid (Stylo servo-mode can't resolve relative URLs without a base) - Painted as image shader in background layer stack **grida-dev integration:** - collect_image_urls() scans HTML for referenced image URLs - load_html_images() resolves local paths + fetches remote in parallel - Images registered via Renderer::add_image_by_url() (arbitrary keys) - HTML files from CLI always use embed mode with image preloading **Test fixtures:** - replaced-img.html — element variants and object-fit - paint-background-image.html — background-image: url() specimens - mixed-images.html — cards combining both image types Co-Authored-By: Claude Opus 4.6 (1M context) --- .../grida-canvas/examples/golden_htmlcss.rs | 3 +- crates/grida-canvas/src/cache/geometry.rs | 11 +- crates/grida-canvas/src/htmlcss/collect.rs | 92 ++++- crates/grida-canvas/src/htmlcss/layout.rs | 89 ++++- crates/grida-canvas/src/htmlcss/mod.rs | 365 ++++++++++++++++-- crates/grida-canvas/src/htmlcss/paint.rs | 182 +++++++-- crates/grida-canvas/src/htmlcss/style.rs | 62 ++- crates/grida-canvas/src/htmlcss/types.rs | 44 +++ crates/grida-canvas/src/layout/tree.rs | 1 + crates/grida-canvas/src/painter/painter.rs | 2 + .../src/runtime/image_repository.rs | 29 ++ crates/grida-canvas/src/runtime/scene.rs | 8 + crates/grida-canvas/src/window/application.rs | 15 +- crates/grida-dev/src/editor/document.rs | 2 + crates/grida-dev/src/main.rs | 201 ++++++++-- docs/wg/feat-2d/htmlcss.md | 43 ++- .../chromium/external-resource-loading.md | 326 ++++++++++++++++ docs/wg/research/chromium/index.md | 1 + fixtures/test-html/L0/mixed-images.html | 157 ++++++++ .../test-html/L0/paint-background-image.html | 130 +++++++ fixtures/test-html/L0/replaced-img.html | 208 ++++++++++ 21 files changed, 1840 insertions(+), 131 deletions(-) create mode 100644 docs/wg/research/chromium/external-resource-loading.md create mode 100644 fixtures/test-html/L0/mixed-images.html create mode 100644 fixtures/test-html/L0/paint-background-image.html create mode 100644 fixtures/test-html/L0/replaced-img.html diff --git a/crates/grida-canvas/examples/golden_htmlcss.rs b/crates/grida-canvas/examples/golden_htmlcss.rs index bd88bc888a..38da7915bd 100644 --- a/crates/grida-canvas/examples/golden_htmlcss.rs +++ b/crates/grida-canvas/examples/golden_htmlcss.rs @@ -24,7 +24,8 @@ fn fonts() -> FontRepository { fn render_to_png(html: &str, width: f32, name: &str, out_dir: &Path) { let fonts = fonts(); - let picture = htmlcss::render(html, width, 600.0, &fonts).expect("render failed"); + let picture = + htmlcss::render(html, width, 600.0, &fonts, &htmlcss::NoImages).expect("render failed"); let cull = picture.cull_rect(); let w = cull.width().max(1.0) as i32; let h = cull.height().max(1.0) as i32; diff --git a/crates/grida-canvas/src/cache/geometry.rs b/crates/grida-canvas/src/cache/geometry.rs index 946f78e37e..6d8865b39e 100644 --- a/crates/grida-canvas/src/cache/geometry.rs +++ b/crates/grida-canvas/src/cache/geometry.rs @@ -746,9 +746,14 @@ fn resolve_layout( h.max(MIN_SIZE) } else { let styled_html = crate::htmlcss::markdown_to_styled_html(&n.markdown); - crate::htmlcss::measure_content_height(&styled_html, width, fonts) - .unwrap_or(0.0) - .max(MIN_SIZE) + crate::htmlcss::measure_content_height( + &styled_html, + width, + fonts, + &crate::htmlcss::NoImages, + ) + .unwrap_or(0.0) + .max(MIN_SIZE) }; (geo.schema_transform, width, height) } else { diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index 2ad820e711..c590463dec 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -250,6 +250,22 @@ fn collect_element_with_counter( let is_void_widget = detect_widget(&tag, node_data, dom, &mut el); + // Extract object-fit from Stylo for replaced elements () + if el.replaced.is_some() { + use style::properties::longhands::object_fit::computed_value::T as StyloObjectFit; + let of = style.get_position().clone_object_fit(); + let object_fit = match of { + StyloObjectFit::Fill => types::ObjectFit::Fill, + StyloObjectFit::Contain => types::ObjectFit::Contain, + StyloObjectFit::Cover => types::ObjectFit::Cover, + StyloObjectFit::None => types::ObjectFit::None, + StyloObjectFit::ScaleDown => types::ObjectFit::ScaleDown, + }; + if let Some(ref mut replaced) = el.replaced { + replaced.object_fit = object_fit; + } + } + // Collect children, merging consecutive inline content into InlineGroups let mut pending_inline: Vec = Vec::new(); let parent_text_align = el.font.text_align; @@ -294,7 +310,7 @@ fn collect_element_with_counter( // for sizing to work — don't flatten them into inline groups. let is_inline = child.display == types::Display::Inline || child.display == types::Display::InlineBlock; - if is_inline && !child.widget.is_widget() { + if is_inline && !child.widget.is_widget() && child.replaced.is_none() { collect_inline_items(&child, &mut pending_inline); } else { flush_inline_group( @@ -328,10 +344,44 @@ const PLACEHOLDER_COLOR: CGColor = CGColor { /// Detect form control elements and populate `StyledElement::widget`. /// +// ─── Replaced element () detection ──────────────────────────── + +/// Extract `` attributes into a `ReplacedContent`. +/// +/// Follows the HTML spec for replaced elements: +/// - `src` — image URL +/// - `alt` — alternative text (for placeholder display) +/// - `width`/`height` — intrinsic size hints +fn detect_img_element(node: &DemoNode) -> ReplacedContent { + let src = get_element_attr(node, "src").unwrap_or_default(); + let alt = get_element_attr(node, "alt"); + let attr_width = get_element_attr(node, "width").and_then(|s| s.parse::().ok()); + let attr_height = get_element_attr(node, "height").and_then(|s| s.parse::().ok()); + + ReplacedContent { + src, + alt, + attr_width, + attr_height, + object_fit: types::ObjectFit::Fill, // HTML spec default for + } +} + +// ─── Widget (form control) detection ──────────────────────────────── + /// Returns `true` for void elements (like ``) whose DOM children /// should be skipped. fn detect_widget(tag: &str, node_data: &DemoNode, dom: &DemoDom, el: &mut StyledElement) -> bool { match tag { + "img" => { + el.replaced = Some(detect_img_element(node_data)); + // is a replaced inline element — force inline-block so it + // gets its own Taffy node (not merged into InlineGroup). + if el.display == types::Display::Inline { + el.display = types::Display::InlineBlock; + } + true // is a void element + } "input" => { detect_input_widget(node_data, el); true // is a void element @@ -1121,10 +1171,10 @@ fn extract_background(style: &ComputedValues) -> Vec { } } - // 2. Background image layers (gradients on top) + // 2. Background image layers (gradients and URL images on top) for image in bg.background_image.0.iter() { - if let GenericImage::Gradient(gradient) = image { - match gradient.as_ref() { + match image { + GenericImage::Gradient(gradient) => match gradient.as_ref() { GenericGradient::Linear { direction, items, .. } => { @@ -1133,26 +1183,48 @@ fn extract_background(style: &ComputedValues) -> Vec { continue; } let angle_deg = extract_gradient_angle(direction); - layers.push(BackgroundLayer::LinearGradient(LinearGradient { - angle_deg, - stops, - })); + layers.push(BackgroundLayer::Image(StyleImage::LinearGradient( + LinearGradient { angle_deg, stops }, + ))); } GenericGradient::Radial { items, .. } => { let stops = gradient_items_to_stops(items); if stops.is_empty() { continue; } - layers.push(BackgroundLayer::RadialGradient(RadialGradient { stops })); + layers.push(BackgroundLayer::Image(StyleImage::RadialGradient( + RadialGradient { stops }, + ))); } GenericGradient::Conic { items, .. } => { let stops = conic_gradient_items_to_stops(items); if stops.is_empty() { continue; } - layers.push(BackgroundLayer::ConicGradient(ConicGradient { stops })); + layers.push(BackgroundLayer::Image(StyleImage::ConicGradient( + ConicGradient { stops }, + ))); + } + }, + GenericImage::Url(computed_url) => { + // Extract URL string from Stylo's ComputedUrl. + // ComputedUrl::Valid(Arc) — resolved absolute URL + // ComputedUrl::Invalid(Arc) — unresolved (no base URL) + // + // Our HTML has no document base URL, so relative URLs like + // "bg.png" become ComputedUrl::Invalid. We accept both forms. + use style::values::computed::url::ComputedUrl; + let url_str = match computed_url { + ComputedUrl::Valid(url) => url.as_str().to_string(), + ComputedUrl::Invalid(s) => s.to_string(), + }; + if !url_str.is_empty() { + layers.push(BackgroundLayer::Image(StyleImage::Url(url_str))); } } + _ => { + // Other image types (e.g. element(), image-set()) — skip + } } } diff --git a/crates/grida-canvas/src/htmlcss/layout.rs b/crates/grida-canvas/src/htmlcss/layout.rs index e2b6970b50..766f130489 100644 --- a/crates/grida-canvas/src/htmlcss/layout.rs +++ b/crates/grida-canvas/src/htmlcss/layout.rs @@ -28,7 +28,7 @@ pub struct LayoutBox<'a> { pub children: Vec>, } -/// A positioned node — either a box, text, or inline group. +/// A positioned node — either a box, text, inline group, or replaced element. #[derive(Debug)] pub enum LayoutNode<'a> { Box(LayoutBox<'a>), @@ -53,6 +53,7 @@ pub fn compute_layout<'a>( root: &'a StyledElement, available_width: f32, fonts: &FontRepository, + images: &dyn super::ImageProvider, ) -> LayoutBox<'a> { let font_collection = fonts.font_collection(); let mut taffy: TaffyTree = TaffyTree::new(); @@ -62,7 +63,7 @@ pub fn compute_layout<'a>( taffy.disable_rounding(); // Build Taffy tree - let taffy_root = build_taffy_node(&mut taffy, root, font_collection); + let taffy_root = build_taffy_node(&mut taffy, root, font_collection, images); // Run layout with text measurement callback let fc = font_collection.clone(); @@ -86,11 +87,12 @@ pub fn compute_content_height( root: &StyledElement, available_width: f32, fonts: &FontRepository, + images: &dyn super::ImageProvider, ) -> f32 { let font_collection = fonts.font_collection(); let mut taffy: TaffyTree = TaffyTree::new(); taffy.disable_rounding(); - let taffy_root = build_taffy_node(&mut taffy, root, font_collection); + let taffy_root = build_taffy_node(&mut taffy, root, font_collection, images); let fc = font_collection.clone(); let _ = taffy.compute_layout_with_measure( taffy_root, @@ -113,8 +115,16 @@ fn build_taffy_node( taffy: &mut TaffyTree, el: &StyledElement, fonts: &FontCollection, + images: &dyn super::ImageProvider, ) -> TaffyNodeId { - let style = element_to_taffy_style(el); + let mut style = element_to_taffy_style(el); + + // For replaced elements (), apply intrinsic sizing where CSS + // didn't specify explicit dimensions. This follows the HTML spec: + // CSS width/height > image data dimensions > HTML attrs > 300×150 fallback. + if let Some(ref replaced) = el.replaced { + apply_replaced_intrinsic_size(&mut style, replaced, images); + } // Build child nodes let mut child_ids: Vec = Vec::new(); @@ -123,7 +133,7 @@ fn build_taffy_node( match child { StyledNode::Element(child_el) => { if child_el.display != types::Display::None { - child_ids.push(build_taffy_node(taffy, child_el, fonts)); + child_ids.push(build_taffy_node(taffy, child_el, fonts, images)); } } StyledNode::Text(run) => { @@ -245,6 +255,75 @@ fn text_measure_func( } /// Convert StyledElement to Taffy Style. +/// Apply replaced element sizing for ``. +/// +/// Per the HTML spec, the sizing priority is: +/// 1. **CSS `width`/`height`** — already in the taffy style from `element_to_taffy_style` +/// 2. **HTML `width`/`height` attributes** — presentational hints that act like CSS +/// (Stylo servo-mode may not map these, so we apply them explicitly) +/// 3. **Image natural dimensions** — used only when no size is specified at all +/// 4. **300×150 fallback** — HTML spec default for replaced elements +/// +/// The image's natural dimensions always contribute an **aspect ratio** so that +/// when only one axis is constrained, Taffy computes the other proportionally. +fn apply_replaced_intrinsic_size( + style: &mut taffy::Style, + replaced: &ReplacedContent, + images: &dyn super::ImageProvider, +) { + let css_w_is_auto = style.size.width == Dimension::auto(); + let css_h_is_auto = style.size.height == Dimension::auto(); + + // Natural aspect ratio from image data (most accurate) + let img_size = images.get_size(&replaced.src); + if let Some((nw, nh)) = img_size { + if nw > 0 && nh > 0 { + style.aspect_ratio = Some(nw as f32 / nh as f32); + } + } + + // HTML width/height attributes are presentational hints — they act like + // CSS width/height. Apply them when CSS didn't set explicit values. + // This is needed because Stylo servo-mode doesn't map dimension + // attributes to CSS properties. + if css_w_is_auto { + if let Some(aw) = replaced.attr_width { + style.size.width = Dimension::length(aw as f32); + } + } + if css_h_is_auto { + if let Some(ah) = replaced.attr_height { + style.size.height = Dimension::length(ah as f32); + } + } + + // If still auto after HTML attrs, use natural image dimensions + let w_is_auto = style.size.width == Dimension::auto(); + let h_is_auto = style.size.height == Dimension::auto(); + + if w_is_auto { + if let Some((nw, _)) = img_size { + style.size.width = Dimension::length(nw as f32); + } else if h_is_auto { + // No size info at all → 300×150 fallback + style.size.width = Dimension::length(300.0); + style.size.height = Dimension::length(150.0); + } + // else: width auto + height set + aspect_ratio → Taffy resolves + } + + if style.size.height == Dimension::auto() { + if let Some((_, nh)) = img_size { + // Only set natural height if width wasn't explicitly set to + // something different (aspect_ratio handles that case) + if style.aspect_ratio.is_none() { + style.size.height = Dimension::length(nh as f32); + } + } + // else: height auto + width set + aspect_ratio → Taffy resolves + } +} + fn element_to_taffy_style(el: &StyledElement) -> taffy::Style { let mut style = taffy::Style { display: match el.display { diff --git a/crates/grida-canvas/src/htmlcss/mod.rs b/crates/grida-canvas/src/htmlcss/mod.rs index 67bc066941..7cc5c9ab06 100644 --- a/crates/grida-canvas/src/htmlcss/mod.rs +++ b/crates/grida-canvas/src/htmlcss/mod.rs @@ -20,12 +20,125 @@ pub mod types; use crate::runtime::font_repository::FontRepository; use github_markdown::GITHUB_MARKDOWN_CSS; +// ─── Image provider trait ─────────────────────────────────────────── + +/// Host-provided image resolver for the htmlcss rendering pipeline. +/// +/// Inspired by Chromium's `ImageResourceContent` + `ImageResourceObserver` +/// pattern. The htmlcss module asks for an image by URL; the host decides +/// how and when to provide it. The trait is intentionally minimal — +/// no lifecycle management, no caching, no fetching. Those are host concerns. +/// +/// # Use Cases +/// +/// - **CLI (pre-resolved):** Host loads all images before calling `render()`. +/// A `HashMap` wrapper implements this trivially. +/// - **WASM (async drain):** Host renders with missing images → inspects +/// placeholder output → fetches missing URLs → re-renders. +/// - **Native app:** Any `ResourceFetcher` implementation the host provides. +pub trait ImageProvider { + /// Resolve a URL to a decoded Skia image. + /// + /// Returns `None` if the image is not (yet) available. Implementations + /// may record the miss for later fetching (drain-missing pattern). + fn get(&self, url: &str) -> Option<&skia_safe::Image>; + + /// Get intrinsic dimensions without requiring the full decoded image. + /// + /// Used during layout for replaced elements when decode may be deferred. + /// Default implementation delegates to `get()` and reads dimensions. + fn get_size(&self, url: &str) -> Option<(u32, u32)> { + self.get(url) + .map(|img| (img.width() as u32, img.height() as u32)) + } +} + +/// Null image provider — always returns `None`. +/// +/// Zero-cost default for image-free rendering. Use when the HTML content +/// contains no images, or when images are intentionally not provided. +pub struct NoImages; + +impl ImageProvider for NoImages { + fn get(&self, _url: &str) -> Option<&skia_safe::Image> { + None + } +} + +/// Pre-loaded image provider backed by a `HashMap`. +/// +/// The "pre-resolved" flow: load all images before calling `render()`. +/// Suitable for CLI tools, export pipelines, and test harnesses. +/// +/// # Usage +/// +/// ```ignore +/// let mut images = PreloadedImages::new(); +/// images.insert("https://example.com/photo.jpg", decoded_skia_image); +/// let picture = htmlcss::render(html, width, height, &fonts, &images)?; +/// ``` +pub struct PreloadedImages { + images: std::collections::HashMap, +} + +impl PreloadedImages { + pub fn new() -> Self { + Self { + images: std::collections::HashMap::new(), + } + } + + /// Insert a decoded Skia image keyed by its URL. + pub fn insert(&mut self, url: impl Into, image: skia_safe::Image) { + self.images.insert(url.into(), image); + } + + /// Number of loaded images. + pub fn len(&self) -> usize { + self.images.len() + } + + /// Whether no images are loaded. + pub fn is_empty(&self) -> bool { + self.images.is_empty() + } + + /// Decode image bytes (PNG, JPEG, WebP, GIF) into a Skia `Image` and insert. + /// + /// Returns `Some((width, height))` on success, `None` if decode fails. + pub fn insert_bytes(&mut self, url: impl Into, bytes: &[u8]) -> Option<(u32, u32)> { + let data = skia_safe::Data::new_copy(bytes); + let image = skia_safe::Image::from_encoded(data)?; + let w = image.width() as u32; + let h = image.height() as u32; + self.images.insert(url.into(), image); + Some((w, h)) + } +} + +impl Default for PreloadedImages { + fn default() -> Self { + Self::new() + } +} + +impl ImageProvider for PreloadedImages { + fn get(&self, url: &str) -> Option<&skia_safe::Image> { + self.images.get(url) + } +} + /// Render HTML+CSS to a Skia Picture. +/// +/// Images referenced by `` or `background-image: url()` are +/// resolved via the `images` provider at layout and paint time. Missing +/// images render as placeholders — the pipeline never blocks on loads. pub fn render( html: &str, width: f32, _height: f32, fonts: &FontRepository, + images: &dyn ImageProvider, ) -> Result { let root = collect::collect_styled_tree(html)?; let Some(root) = root else { @@ -38,7 +151,7 @@ pub fn render( .expect("empty picture")); }; - let layout_root = layout::compute_layout(&root, width, fonts); + let layout_root = layout::compute_layout(&root, width, fonts, images); let content_height = layout_root.height; Ok(paint::paint_to_picture( @@ -46,6 +159,7 @@ pub fn render( width, content_height, fonts, + images, )) } @@ -56,12 +170,66 @@ pub fn measure_content_height( html: &str, width: f32, fonts: &FontRepository, + images: &dyn ImageProvider, ) -> Result { let root = collect::collect_styled_tree(html)?; let Some(root) = root else { return Ok(0.0); }; - Ok(layout::compute_content_height(&root, width, fonts)) + Ok(layout::compute_content_height(&root, width, fonts, images)) +} + +/// Collect all image URLs referenced in HTML content. +/// +/// Runs the Stylo cascade to resolve CSS `background-image: url()` values, +/// then walks the styled tree to extract all image URLs from: +/// - `` elements +/// - `background-image: url("...")` CSS properties +/// +/// Use this to pre-load images before calling [`render()`] (CLI / pre-resolved flow). +/// +/// # Example +/// +/// ```ignore +/// let urls = htmlcss::collect_image_urls(html)?; +/// let images = load_all(urls).await; // your loader +/// let picture = htmlcss::render(html, width, height, &fonts, &images)?; +/// ``` +pub fn collect_image_urls(html: &str) -> Result, String> { + let root = collect::collect_styled_tree(html)?; + let Some(root) = root else { + return Ok(Vec::new()); + }; + let mut urls = Vec::new(); + collect_urls_from_element(&root, &mut urls); + urls.sort(); + urls.dedup(); + Ok(urls) +} + +fn collect_urls_from_element(el: &style::StyledElement, urls: &mut Vec) { + // Replaced content () + if let Some(ref replaced) = el.replaced { + if !replaced.src.is_empty() { + urls.push(replaced.src.clone()); + } + } + + // Background image URLs + for layer in &el.background { + if let style::BackgroundLayer::Image(style::StyleImage::Url(url)) = layer { + if !url.is_empty() { + urls.push(url.clone()); + } + } + } + + // Recurse into children + for child in &el.children { + if let style::StyledNode::Element(child_el) = child { + collect_urls_from_element(child_el, urls); + } + } } /// Convert GFM markdown to a self-contained HTML document with GitHub-flavored CSS. @@ -86,11 +254,26 @@ mod tests { FontRepository::new(Arc::new(Mutex::new(ByteStore::new()))) } + /// Test helper: render with no image provider. + fn test_render( + html: &str, + width: f32, + height: f32, + fonts: &FontRepository, + ) -> Result { + render(html, width, height, fonts, &NoImages) + } + + /// Test helper: measure with no image provider. + fn test_measure(html: &str, width: f32, fonts: &FontRepository) -> Result { + measure_content_height(html, width, fonts, &NoImages) + } + #[test] fn test_render_empty() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render("", 400.0, 300.0, &fonts); + let pic = test_render("", 400.0, 300.0, &fonts); assert!(pic.is_ok()); } @@ -98,7 +281,7 @@ mod tests { fn test_render_heading() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render("

Hello

", 400.0, 300.0, &fonts).unwrap(); + let pic = test_render("

Hello

", 400.0, 300.0, &fonts).unwrap(); assert!(pic.cull_rect().width() > 0.0); } @@ -106,7 +289,7 @@ mod tests { fn test_render_with_style_block() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( "

Blue

", 400.0, 300.0, @@ -119,7 +302,7 @@ mod tests { fn test_render_table() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( "
AB
", 600.0, 300.0, @@ -132,7 +315,7 @@ mod tests { fn test_render_flex() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"
A
B
"#, 400.0, 300.0, @@ -173,7 +356,7 @@ mod tests { ); // Check layout produces side-by-side boxes - let layout_root = layout::compute_layout(&root, 400.0, &fonts); + let layout_root = layout::compute_layout(&root, 400.0, &fonts, &NoImages); fn find_grid_layout<'a>( lb: &'a layout::LayoutBox<'a>, ) -> Option<&'a layout::LayoutBox<'a>> { @@ -219,7 +402,7 @@ mod tests { fn test_render_grid_basic() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"
A
B
C
"#, @@ -234,7 +417,7 @@ mod tests { fn test_render_grid_fr() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"
1fr
2fr
1fr
"#, @@ -249,7 +432,7 @@ mod tests { fn test_render_grid_repeat() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"
1
2
3
4
"#, @@ -264,7 +447,7 @@ mod tests { fn test_render_grid_span() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"
wide
1x1
@@ -281,7 +464,7 @@ mod tests { fn test_render_grid_auto_flow_dense() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"
wide
a
@@ -299,7 +482,7 @@ mod tests { fn test_render_box_shadow_outer() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"
shadow
"#, 300.0, 200.0, @@ -312,7 +495,7 @@ mod tests { fn test_render_box_shadow_inset() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"
inset
"#, 300.0, 200.0, @@ -325,7 +508,7 @@ mod tests { fn test_render_box_shadow_combined() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"
both
"#, 300.0, 200.0, @@ -369,7 +552,7 @@ mod tests { fn test_render_opacity() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"

Semi-transparent

"#, 400.0, 300.0, @@ -439,7 +622,7 @@ mod tests { fn test_measure_height() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let h = measure_content_height("

Hello

", 400.0, &fonts).unwrap(); + let h = test_measure("

Hello

", 400.0, &fonts).unwrap(); assert!(h > 0.0, "Content height should be positive, got {h}"); } @@ -447,7 +630,7 @@ mod tests { fn test_head_hidden() { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); - let pic = render( + let pic = test_render( r#"

V

"#, 400.0, 300.0, @@ -465,7 +648,7 @@ mod tests { let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = markdown_to_styled_html("# Hello World"); - let pic = render(&html, 400.0, 300.0, &fonts); + let pic = test_render(&html, 400.0, 300.0, &fonts); assert!(pic.is_ok(), "Markdown heading should render"); assert!(pic.unwrap().cull_rect().height() > 0.0); } @@ -493,7 +676,7 @@ code block 2. Second "#; let html = markdown_to_styled_html(md); - let pic = render(&html, 600.0, 300.0, &fonts); + let pic = test_render(&html, 600.0, 300.0, &fonts); assert!(pic.is_ok(), "Mixed markdown content should render"); let h = pic.unwrap().cull_rect().height(); assert!( @@ -513,7 +696,7 @@ code block | Bob | 25 | London | "#; let html = markdown_to_styled_html(md); - let pic = render(&html, 600.0, 300.0, &fonts); + let pic = test_render(&html, 600.0, 300.0, &fonts); assert!(pic.is_ok(), "Markdown table should render"); } @@ -522,7 +705,7 @@ code block let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = markdown_to_styled_html(""); - let pic = render(&html, 400.0, 300.0, &fonts); + let pic = test_render(&html, 400.0, 300.0, &fonts); assert!(pic.is_ok(), "Empty markdown should render"); } @@ -719,7 +902,7 @@ code block let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = include_str!("../../../../fixtures/test-html/L0/transform-2d.html"); - let pic = render(html, 800.0, 600.0, &fonts); + let pic = test_render(html, 800.0, 600.0, &fonts); assert!(pic.is_ok(), "transform-2d.html should render without error"); } @@ -728,7 +911,7 @@ code block let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = r#"
origin
"#; - let pic = render(html, 400.0, 300.0, &fonts); + let pic = test_render(html, 400.0, 300.0, &fonts); assert!(pic.is_ok()); } @@ -737,7 +920,7 @@ code block let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = include_str!("../../../../fixtures/test-html/L0/transform-origin.html"); - let pic = render(html, 800.0, 600.0, &fonts); + let pic = test_render(html, 800.0, 600.0, &fonts); assert!( pic.is_ok(), "transform-origin.html should render without error" @@ -749,7 +932,7 @@ code block let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = include_str!("../../../../fixtures/test-html/L0/transform-nested.html"); - let pic = render(html, 800.0, 600.0, &fonts); + let pic = test_render(html, 800.0, 600.0, &fonts); assert!( pic.is_ok(), "transform-nested.html should render without error" @@ -762,7 +945,7 @@ code block let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = r#"
T
"#; - let pic = render(html, 400.0, 300.0, &fonts); + let pic = test_render(html, 400.0, 300.0, &fonts); assert!(pic.is_ok()); } @@ -772,7 +955,7 @@ code block let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = r#"
T
"#; - let pic = render(html, 400.0, 300.0, &fonts); + let pic = test_render(html, 400.0, 300.0, &fonts); assert!(pic.is_ok()); } @@ -800,7 +983,7 @@ code block // is a PushButton rendered as void element // with injected label text. "#; - let pic = render(html, 600.0, 400.0, &fonts); + let pic = test_render(html, 600.0, 400.0, &fonts); assert!(pic.is_ok(), "Mixed form should render"); let h = pic.unwrap().cull_rect().height(); assert!( @@ -974,4 +1157,104 @@ code block "Mixed form should have substantial height, got {h}" ); } + + // ── Image element tests ── + + /// Verify is collected as StyledNode::Replaced. + #[test] + fn test_img_collection() { + let _guard = crate::stylo_test::lock(); + let html = + r#"
A photo
"#; + let root = collect::collect_styled_tree(html).unwrap().unwrap(); + + fn find_replaced(el: &style::StyledElement) -> Option<&style::ReplacedContent> { + if let Some(ref r) = el.replaced { + return Some(r); + } + for child in &el.children { + if let style::StyledNode::Element(e) = child { + if let Some(found) = find_replaced(e) { + return Some(found); + } + } + } + None + } + + let replaced = + find_replaced(&root).expect("Should find element with replaced content for "); + assert_eq!(replaced.src, "test://photo.jpg"); + assert_eq!(replaced.alt.as_deref(), Some("A photo")); + assert_eq!(replaced.attr_width, Some(200)); + assert_eq!(replaced.attr_height, Some(150)); + } + + /// Verify renders with placeholder (no crash) when no images provided. + #[test] + fn test_img_render_placeholder() { + let _guard = crate::stylo_test::lock(); + let fonts = test_fonts(); + let pic = test_render( + r#"
"#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok(), " with NoImages should render placeholder"); + let h = pic.unwrap().cull_rect().height(); + assert!(h > 0.0, "Should have positive height, got {h}"); + } + + /// Verify collect_image_urls extracts correct URLs. + #[test] + fn test_collect_image_urls() { + let _guard = crate::stylo_test::lock(); + let html = r#"
+ +
+
"#; + let urls = collect_image_urls(html).unwrap(); + println!("collected URLs: {:?}", urls); + assert!( + urls.contains(&"photo.jpg".to_string()), + "Should find img src, got {:?}", + urls + ); + // Note: background-image url() goes through Stylo ComputedUrl resolution. + // With no base URL, Stylo may resolve "bg.png" to an absolute URL. + // The img src is from raw HTML attributes and is preserved as-is. + } + + /// Verify images render when PreloadedImages has the data. + #[test] + fn test_img_render_with_provider() { + let _guard = crate::stylo_test::lock(); + let fonts = test_fonts(); + + let png_bytes = include_bytes!("../../../../fixtures/images/checker.png"); + let mut images = PreloadedImages::new(); + images.insert_bytes("photo.jpg".to_string(), png_bytes); + + let html = r#"
"#; + let pic = render(html, 400.0, 300.0, &fonts, &images); + assert!(pic.is_ok(), "Should render with image provider"); + } + + /// Verify background-image: url() doesn't crash with NoImages. + #[test] + fn test_background_image_url_placeholder() { + let _guard = crate::stylo_test::lock(); + let fonts = test_fonts(); + let pic = test_render( + r#"
content
"#, + 400.0, + 300.0, + &fonts, + ); + assert!( + pic.is_ok(), + "background-image url with NoImages should render" + ); + } } diff --git a/crates/grida-canvas/src/htmlcss/paint.rs b/crates/grida-canvas/src/htmlcss/paint.rs index 50cef0a077..13fa2f4318 100644 --- a/crates/grida-canvas/src/htmlcss/paint.rs +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -16,9 +16,11 @@ use skia_safe::{Canvas, ClipOp, Color, Paint, PaintStyle, PictureRecorder, Rect} use super::layout::{build_skia_text_style, LayoutBox, LayoutNode}; use super::style::{ BackgroundLayer, BorderSide, ConicGradient, GradientStop, InlineBoxDecoration, InlineGroup, - InlineRunItem, LinearGradient, RadialGradient, StyledElement, TextRun, WidgetAppearance, + InlineRunItem, LinearGradient, RadialGradient, StyleImage, StyledElement, TextRun, + WidgetAppearance, }; use super::types; +use super::ImageProvider; /// Paint a `LayoutBox` tree into a Skia `Picture`. pub fn paint_to_picture( @@ -26,13 +28,14 @@ pub fn paint_to_picture( width: f32, height: f32, fonts: &FontRepository, + images: &dyn ImageProvider, ) -> skia_safe::Picture { let font_collection = fonts.font_collection(); let mut recorder = PictureRecorder::new(); let bounds = Rect::from_wh(width, height.max(1.0)); let canvas = recorder.begin_recording(bounds, false); - paint_box(canvas, root, font_collection); + paint_box(canvas, root, font_collection, images); // Marker rect so Skia preserves the cull rect { @@ -49,7 +52,12 @@ pub fn paint_to_picture( // ─── Recursive box painter (Chromium: BoxFragmentPainter) ──────────── -fn paint_box(canvas: &Canvas, layout: &LayoutBox, fonts: &FontCollection) { +fn paint_box( + canvas: &Canvas, + layout: &LayoutBox, + fonts: &FontCollection, + images: &dyn ImageProvider, +) { let style = layout.style; // Visibility check (Chromium: early return in PaintObject) @@ -113,15 +121,30 @@ fn paint_box(canvas: &Canvas, layout: &LayoutBox, fonts: &FontCollection) { // Widget background painted first so CSS background/border can override. paint_widget_background(canvas, style, w, h); paint_box_shadow_outer(canvas, style, w, h); - paint_background(canvas, style, w, h); + paint_background(canvas, style, w, h, images); paint_borders(canvas, style, w, h); paint_box_shadow_inset(canvas, style, w, h); + // ── Phase 1.5: Replaced content () ── + // Replaced elements paint their image content instead of children. + if let Some(ref replaced) = style.replaced { + paint_replaced( + canvas, + replaced, + 0.0, + 0.0, + w, + h, + &style.border_radius, + images, + ); + } + // ── Phase 2: Children (Chromium: kForeground for inlines, recurse for blocks) ── for child in &layout.children { match child { LayoutNode::Box(child_box) => { - paint_box(canvas, child_box, fonts); + paint_box(canvas, child_box, fonts, images); } LayoutNode::Text { run, @@ -207,7 +230,13 @@ fn mat_mul(a: [f32; 6], b: [f32; 6]) -> [f32; 6] { // ─── Background painting (Chromium: BoxPainterBase::PaintFillLayers) ── -fn paint_background(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) { +fn paint_background( + canvas: &Canvas, + style: &StyledElement, + w: f32, + h: f32, + images: &dyn ImageProvider, +) { if style.background.is_empty() { return; } @@ -227,25 +256,52 @@ fn paint_background(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) { } paint.set_color(Color::from_argb(c.a, c.r, c.g, c.b)); } - BackgroundLayer::LinearGradient(grad) => { - if let Some(shader) = build_linear_gradient_shader(grad, w, h) { - paint.set_shader(shader); - } else { - continue; - } - } - BackgroundLayer::RadialGradient(grad) => { - if let Some(shader) = build_radial_gradient_shader(grad, w, h) { - paint.set_shader(shader); - } else { - continue; - } - } - BackgroundLayer::ConicGradient(grad) => { - if let Some(shader) = build_conic_gradient_shader(grad, w, h) { - paint.set_shader(shader); - } else { - continue; + BackgroundLayer::Image(style_image) => { + match style_image { + StyleImage::LinearGradient(grad) => { + if let Some(shader) = build_linear_gradient_shader(grad, w, h) { + paint.set_shader(shader); + } else { + continue; + } + } + StyleImage::RadialGradient(grad) => { + if let Some(shader) = build_radial_gradient_shader(grad, w, h) { + paint.set_shader(shader); + } else { + continue; + } + } + StyleImage::ConicGradient(grad) => { + if let Some(shader) = build_conic_gradient_shader(grad, w, h) { + paint.set_shader(shader); + } else { + continue; + } + } + StyleImage::Url(url) => { + if let Some(image) = images.get(url) { + // Default: stretch image to fill the background area. + // TODO: background-size, background-position, background-repeat + let src_rect = + Rect::from_wh(image.width() as f32, image.height() as f32); + canvas.save(); + if !r.is_zero() { + let mut rrect = skia_safe::RRect::new(); + rrect.set_rect_radii(rect, &r.to_skia_radii()); + canvas.clip_rrect(rrect, ClipOp::Intersect, true); + } + canvas.draw_image_rect( + image, + Some((&src_rect, skia_safe::canvas::SrcRectConstraint::Fast)), + rect, + &paint, + ); + canvas.restore(); + } + // Missing image: skip layer (non-blocking) + continue; + } } } } @@ -260,6 +316,82 @@ fn paint_background(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) { } } +// ─── Replaced element painting (Chromium: ReplacedPainter) ────────── + +/// Paint a replaced element (``). +/// +/// If the image is available via `ImageProvider`, it is drawn with +/// `object-fit` semantics. If unavailable, a placeholder is painted. +fn paint_replaced( + canvas: &Canvas, + content: &super::style::ReplacedContent, + x: f32, + y: f32, + w: f32, + h: f32, + border_radius: &super::style::CornerRadii, + images: &dyn ImageProvider, +) { + canvas.save(); + canvas.translate((x, y)); + + let dest_rect = Rect::from_xywh(0.0, 0.0, w, h); + + // Clip to the element's content box. Replaced elements never paint + // outside their box (object-fit: none/cover can produce oversized dst + // rects that must be clipped). Border-radius further refines this. + if !border_radius.is_zero() { + let mut rrect = skia_safe::RRect::new(); + rrect.set_rect_radii(dest_rect, &border_radius.to_skia_radii()); + canvas.clip_rrect(rrect, ClipOp::Intersect, true); + } else { + canvas.clip_rect(dest_rect, ClipOp::Intersect, true); + } + + if let Some(image) = images.get(&content.src) { + let img_w = image.width() as f32; + let img_h = image.height() as f32; + + let box_fit = content.object_fit.to_box_fit(img_w, img_h, w, h); + let t = box_fit.calculate_transform((img_w, img_h), (w, h)); + + let paint = Paint::default(); + canvas.save(); + canvas.concat(&skia_safe::Matrix::new_all( + t.matrix[0][0], + t.matrix[0][1], + t.matrix[0][2], + t.matrix[1][0], + t.matrix[1][1], + t.matrix[1][2], + 0.0, + 0.0, + 1.0, + )); + canvas.draw_image(image, (0.0, 0.0), Some(&paint)); + canvas.restore(); + } else { + // Placeholder: light gray rect + let mut paint = Paint::default(); + paint.set_color(Color::from_argb(255, 238, 238, 238)); + paint.set_style(PaintStyle::Fill); + canvas.draw_rect(dest_rect, &paint); + + // Light border + let mut border_paint = Paint::default(); + border_paint.set_color(Color::from_argb(255, 204, 204, 204)); + border_paint.set_style(PaintStyle::Stroke); + border_paint.set_stroke_width(1.0); + canvas.draw_rect(dest_rect, &border_paint); + + // NOTE: alt text rendering intentionally omitted — placeholder rect + // is preferred for visual consistency. Alt text could be added here + // via: if let Some(ref alt) = content.alt { paint_alt_text(...) } + } + + canvas.restore(); +} + // ─── Gradient shaders ──────────────────────────────────────────────── use skia_safe::gradient_shader::{Gradient, GradientColors, Interpolation}; diff --git a/crates/grida-canvas/src/htmlcss/style.rs b/crates/grida-canvas/src/htmlcss/style.rs index cdf7fdca7d..4bc21d1732 100644 --- a/crates/grida-canvas/src/htmlcss/style.rs +++ b/crates/grida-canvas/src/htmlcss/style.rs @@ -54,7 +54,7 @@ pub struct StyledElement { /// Background layers, bottom-to-top. May include solid color and/or gradients. pub background: Vec, pub border_radius: CornerRadii, - // TODO: background-image (url), background-position, background-size, background-repeat + // TODO: background-position, background-size, background-repeat // ── Text / Font (StyleInheritedData — inherited through tree) ── pub color: CGColor, @@ -114,6 +114,11 @@ pub struct StyledElement { /// non-widget elements. pub widget: WidgetAppearance, + // ── Replaced content () ── + /// For replaced elements (``), the external content reference. + /// `None` for normal elements. + pub replaced: Option, + // ── Children ── pub children: Vec, } @@ -130,6 +135,29 @@ pub enum StyledNode { InlineGroup(InlineGroup), } +/// Content of a replaced element (Chromium: `LayoutReplaced`). +/// +/// Replaced elements have intrinsic dimensions from their content, not +/// from CSS. The `src` URL is resolved via `ImageProvider` at paint time. +/// +/// Intrinsic size resolution order (follows HTML spec): +/// 1. Decoded image dimensions (from `ImageProvider::get_size()`) +/// 2. HTML `width`/`height` attributes +/// 3. Default 300×150 (HTML spec fallback for replaced elements) +#[derive(Debug, Clone)] +pub struct ReplacedContent { + /// Image source URL (from HTML `src` attribute). + pub src: String, + /// Alt text for placeholder display when image is unavailable. + pub alt: Option, + /// Intrinsic width hint from HTML `width` attribute. + pub attr_width: Option, + /// Intrinsic height hint from HTML `height` attribute. + pub attr_height: Option, + /// CSS `object-fit` — how the image content fits its box. + pub object_fit: super::types::ObjectFit, +} + /// Consecutive inline items merged into a single paragraph. /// /// Maps to Chromium's flat `InlineItem` list within an inline formatting @@ -281,15 +309,40 @@ pub struct BoxShadow { // ─── Background Sub-types (StyleBackgroundData) ───────────────────── -/// A single background layer — solid color or gradient. +/// A CSS image value — polymorphic like Chromium's `StyleImage`. +/// +/// Gradients are always synchronous (generated at paint time from parameters). +/// URL images are resolved lazily via `ImageProvider` at paint time. +/// +/// Chromium: `StyleImage` base class with subclasses `StyleFetchedImage` +/// (URL-referenced), `StyleGeneratedImage` (gradients), `StylePendingImage`. #[derive(Debug, Clone)] -pub enum BackgroundLayer { - Solid(CGColor), +pub enum StyleImage { + /// `url("...")` — resolved at paint time via `ImageProvider`. + /// Chromium: `StyleFetchedImage` wrapping `ImageResourceContent`. + Url(String), + /// `linear-gradient(...)` — generated at paint time from parameters. LinearGradient(LinearGradient), + /// `radial-gradient(...)` — generated at paint time from parameters. RadialGradient(RadialGradient), + /// `conic-gradient(...)` — generated at paint time from parameters. ConicGradient(ConicGradient), } +/// A single background layer — solid color or image. +/// +/// Mirrors Chromium's `FillLayer` which stores a `StyleImage*` for any +/// background layer type (gradient, url, or none) plus a separate color +/// slot. Our representation flattens this into a two-variant enum. +#[derive(Debug, Clone)] +pub enum BackgroundLayer { + /// Solid color fill (CSS `background-color`). + Solid(CGColor), + /// Image layer: gradient or URL-referenced image. + /// Chromium: `FillLayer::image_` field holding a `StyleImage*`. + Image(StyleImage), +} + /// CSS `linear-gradient()`. #[derive(Debug, Clone)] pub struct LinearGradient { @@ -561,6 +614,7 @@ impl Default for StyledElement { grid_row_start: GridPlacement::default(), grid_row_end: GridPlacement::default(), widget: WidgetAppearance::default(), + replaced: None, children: Vec::new(), } } diff --git a/crates/grida-canvas/src/htmlcss/types.rs b/crates/grida-canvas/src/htmlcss/types.rs index 6ec2f35c87..bbd033f265 100644 --- a/crates/grida-canvas/src/htmlcss/types.rs +++ b/crates/grida-canvas/src/htmlcss/types.rs @@ -280,6 +280,50 @@ pub enum GridPlacement { Span(u16), } +// ─── Replaced element types ──────────────────────────────────────── + +/// CSS `object-fit` for replaced elements (``, `