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 be41cad826..fbae8ca786 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( @@ -326,12 +342,44 @@ const PLACEHOLDER_COLOR: CGColor = CGColor { a: 255, }; -/// 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 @@ -835,6 +883,16 @@ fn extract_style(tag: &str, style: &ComputedValues) -> StyledElement { }; } + // Box sizing (in Stylo, box-sizing is in the "position" property group) + { + let pos = style.get_position(); + use style::properties::longhands::box_sizing::computed_value::T as StyloBoxSizing; + el.box_sizing = match pos.clone_box_sizing() { + StyloBoxSizing::ContentBox => types::BoxSizing::ContentBox, + StyloBoxSizing::BorderBox => types::BoxSizing::BorderBox, + }; + } + // Margin (may be auto or %) el.margin = extract_css_margin(style); @@ -844,6 +902,9 @@ fn extract_style(tag: &str, style: &ComputedValues) -> StyledElement { // Border el.border = extract_border(style); + // Border image (9-slice) + el.border_image = extract_border_image(style); + // Border radius el.border_radius = extract_border_radius(style); @@ -1069,6 +1130,109 @@ fn extract_border(style: &ComputedValues) -> BorderBox { } } +/// Extract CSS `border-image` properties (Chromium: NinePieceImage). +/// +/// Returns `Some(BorderImage)` when `border-image-source` is set to a +/// non-none value. The source image URL is extracted the same way as +/// `background-image: url()` — via `GenericImage::Url` / `ComputedUrl`. +fn extract_border_image(style: &ComputedValues) -> Option { + let b = style.get_border(); + + let source = convert_image(&b.border_image_source)?; + + // border-image-slice: BorderImageSlice { offsets: Rect>, fill } + let slice_computed = &b.border_image_slice; + let s = &slice_computed.offsets; + let resolve_nop = |v: &style::values::computed::NonNegativeNumberOrPercentage| -> f32 { + use style::values::computed::NumberOrPercentage; + match &v.0 { + NumberOrPercentage::Number(n) => *n, + NumberOrPercentage::Percentage(p) => p.0 * 100.0, + } + }; + let slice = EdgeInsets { + top: resolve_nop(&s.0), + right: resolve_nop(&s.1), + bottom: resolve_nop(&s.2), + left: resolve_nop(&s.3), + }; + + // border-image-outset: Rect + let o = &b.border_image_outset; + let resolve_lon = |v: &style::values::computed::NonNegativeLengthOrNumber| -> f32 { + use style::values::generics::length::GenericLengthOrNumber; + match v { + GenericLengthOrNumber::Number(n) => n.0, + GenericLengthOrNumber::Length(lp) => lp.0.px(), + } + }; + let outset = EdgeInsets { + top: resolve_lon(&o.0), + right: resolve_lon(&o.1), + bottom: resolve_lon(&o.2), + left: resolve_lon(&o.3), + }; + + // border-image-repeat: (keyword_x, keyword_y) + let repeat = &b.border_image_repeat; + let map_repeat = + |kw: &style::values::specified::border::BorderImageRepeatKeyword| -> types::BorderImageRepeat { + use style::values::specified::border::BorderImageRepeatKeyword as BIR; + match kw { + BIR::Stretch => types::BorderImageRepeat::Stretch, + BIR::Repeat => types::BorderImageRepeat::Repeat, + BIR::Round => types::BorderImageRepeat::Round, + BIR::Space => types::BorderImageRepeat::Space, + } + }; + + // border-image-width: Rect + // Number(n) is a multiplier of the corresponding border-width. + // LengthPercentage is an absolute value. Auto = use slice value. + let biw = &b.border_image_width; + let border_widths = [ + b.border_top_width.to_f32_px(), + b.border_right_width.to_f32_px(), + b.border_bottom_width.to_f32_px(), + b.border_left_width.to_f32_px(), + ]; + let resolve_bisw = + |v: &style::values::computed::BorderImageSideWidth, border_w: f32| -> Option { + use style::values::generics::border::BorderImageSideWidth as BISW; + match v { + BISW::Number(n) => Some(n.0 * border_w), + BISW::LengthPercentage(lp) => Some(lp.0.to_length().map(|l| l.px()).unwrap_or(0.0)), + BISW::Auto => None, + } + }; + let width = { + let t = resolve_bisw(&biw.0, border_widths[0]); + let r = resolve_bisw(&biw.1, border_widths[1]); + let bv = resolve_bisw(&biw.2, border_widths[2]); + let l = resolve_bisw(&biw.3, border_widths[3]); + if t.is_some() || r.is_some() || bv.is_some() || l.is_some() { + Some(EdgeInsets { + top: t.unwrap_or(0.0), + right: r.unwrap_or(0.0), + bottom: bv.unwrap_or(0.0), + left: l.unwrap_or(0.0), + }) + } else { + None + } + }; + + Some(BorderImage { + source, + slice, + fill: slice_computed.fill, + width, + outset, + repeat_x: map_repeat(&repeat.0), + repeat_y: map_repeat(&repeat.1), + }) +} + /// Extract CSS `outline` properties. /// /// Chromium: `ComputedStyle::OutlineWidth()`, `OutlineColor()`, @@ -1108,6 +1272,60 @@ fn extract_outline(style: &ComputedValues) -> Outline { } } +/// Convert a Stylo `GenericImage` to our `StyleImage`. +/// +/// Shared by `extract_background` and `extract_border_image` — both need +/// the same URL/gradient conversion from Stylo's computed image type. +fn convert_image(image: &style::values::computed::Image) -> Option { + use style::values::computed::url::ComputedUrl; + use style::values::generics::image::{GenericGradient, GenericImage}; + + match image { + GenericImage::None => None, + GenericImage::Url(computed_url) => { + 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() { + None + } else { + Some(StyleImage::Url(url_str)) + } + } + GenericImage::Gradient(gradient) => match gradient.as_ref() { + GenericGradient::Linear { + direction, items, .. + } => { + let stops = gradient_items_to_stops(items); + if stops.is_empty() { + return None; + } + let angle_deg = extract_gradient_angle(direction); + Some(StyleImage::LinearGradient(LinearGradient { + angle_deg, + stops, + })) + } + GenericGradient::Radial { items, .. } => { + let stops = gradient_items_to_stops(items); + if stops.is_empty() { + return None; + } + Some(StyleImage::RadialGradient(RadialGradient { stops })) + } + GenericGradient::Conic { items, .. } => { + let stops = conic_gradient_items_to_stops(items); + if stops.is_empty() { + return None; + } + Some(StyleImage::ConicGradient(ConicGradient { stops })) + } + }, + _ => None, + } +} + fn extract_border_radius(style: &ComputedValues) -> CornerRadii { let b = style.get_border(); let lp = |lp: &style::values::computed::NonNegativeLengthPercentage| -> f32 { @@ -1168,8 +1386,6 @@ fn extract_inset(_style: &ComputedValues) -> CssEdgeInsets { } fn extract_background(style: &ComputedValues) -> Vec { - use style::values::generics::image::{GenericGradient, GenericImage}; - let bg = style.get_background(); let mut layers: Vec = Vec::new(); @@ -1181,38 +1397,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() { - GenericGradient::Linear { - direction, items, .. - } => { - let stops = gradient_items_to_stops(items); - if stops.is_empty() { - continue; - } - let angle_deg = extract_gradient_angle(direction); - layers.push(BackgroundLayer::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 })); - } - GenericGradient::Conic { items, .. } => { - let stops = conic_gradient_items_to_stops(items); - if stops.is_empty() { - continue; - } - layers.push(BackgroundLayer::ConicGradient(ConicGradient { stops })); - } - } + if let Some(style_image) = convert_image(image) { + layers.push(BackgroundLayer::Image(style_image)); } } diff --git a/crates/grida-canvas/src/htmlcss/layout.rs b/crates/grida-canvas/src/htmlcss/layout.rs index 65463f6720..d936d41499 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,8 +255,84 @@ 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 or fallback. + // When no intrinsic info exists at all, use 2:1 default aspect ratio + // (HTML spec: 300×150 default object size). + if img_size.is_none() && style.aspect_ratio.is_none() { + style.aspect_ratio = Some(2.0); // 300/150 + } + + 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); + } + // else: width auto + height set + aspect_ratio → Taffy resolves + } + + if style.size.height == Dimension::auto() { + if let Some((_, nh)) = img_size { + 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 { + box_sizing: match el.box_sizing { + types::BoxSizing::ContentBox => taffy::BoxSizing::ContentBox, + types::BoxSizing::BorderBox => taffy::BoxSizing::BorderBox, + }, display: match el.display { types::Display::Flex => taffy::Display::Flex, types::Display::Grid => taffy::Display::Grid, diff --git a/crates/grida-canvas/src/htmlcss/mod.rs b/crates/grida-canvas/src/htmlcss/mod.rs index 67bc066941..d42aec6343 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,75 @@ 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()); + } + } + } + + // Border image source URL + if let Some(ref bi) = el.border_image { + if let style::StyleImage::Url(url) = &bi.source { + 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 +263,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 +290,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 +298,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 +311,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 +324,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 +365,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 +411,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 +426,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 +441,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 +456,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 +473,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 +491,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 +504,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 +517,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 +561,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 +631,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 +639,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 +657,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 +685,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 +705,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 +714,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 +911,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 +920,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 +929,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 +941,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 +954,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 +964,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 +992,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 +1166,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 05a306fbcd..24057c85e9 100644 --- a/crates/grida-canvas/src/htmlcss/paint.rs +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -17,10 +17,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, Outline, RadialGradient, StyledElement, TextRun, + InlineRunItem, LinearGradient, Outline, RadialGradient, StyleImage, StyledElement, TextRun, WidgetAppearance, }; use super::types; +use super::ImageProvider; /// Paint a `LayoutBox` tree into a Skia `Picture`. pub fn paint_to_picture( @@ -28,13 +29,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 { @@ -51,7 +53,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) @@ -119,15 +126,42 @@ 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_borders(canvas, style, w, h); + paint_background(canvas, style, w, h, images); + paint_borders(canvas, style, w, h, images); paint_box_shadow_inset(canvas, style, w, h); + // ── Phase 1.5: Replaced content () ── + // Paint into the content box (inset by border + padding). + if let Some(ref replaced) = style.replaced { + let bt = style.border.top.width; + let br = style.border.right.width; + let bb = style.border.bottom.width; + let bl = style.border.left.width; + let pt = style.padding.top; + let pr = style.padding.right; + let pb = style.padding.bottom; + let pl = style.padding.left; + let cx = bl + pl; + let cy = bt + pt; + let cw = (w - bl - br - pl - pr).max(0.0); + let ch = (h - bt - bb - pt - pb).max(0.0); + paint_replaced( + canvas, + replaced, + cx, + cy, + cw, + ch, + &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, @@ -219,9 +253,64 @@ fn mat_mul(a: [f32; 6], b: [f32; 6]) -> [f32; 6] { ] } +// ─── StyleImage resolution (Chromium: StyleImage::GetImage) ───────── + +/// Resolve a `StyleImage` to a concrete Skia `Image`. +/// +/// This is the single entry point for converting any CSS image value +/// (url or gradient) to a paintable image. Mirrors Chromium's polymorphic +/// `StyleImage::GetImage()` — the caller never branches on image type. +/// +/// - **Url**: looked up from `ImageProvider`. Returns `None` if unavailable. +/// - **Gradient**: rasterized to a Skia surface at `(w, h)` and snapshotted. +/// This matches Chromium's `GradientGeneratedImage::Create(shader, size)`. +fn resolve_style_image( + style_image: &StyleImage, + w: f32, + h: f32, + images: &dyn ImageProvider, +) -> Option { + match style_image { + StyleImage::Url(url) => images.get(url).cloned(), + StyleImage::LinearGradient(grad) => { + rasterize_gradient(w, h, |w, h| build_linear_gradient_shader(grad, w, h)) + } + StyleImage::RadialGradient(grad) => { + rasterize_gradient(w, h, |w, h| build_radial_gradient_shader(grad, w, h)) + } + StyleImage::ConicGradient(grad) => { + rasterize_gradient(w, h, |w, h| build_conic_gradient_shader(grad, w, h)) + } + } +} + +/// Rasterize a gradient shader to a Skia `Image` at the given size. +fn rasterize_gradient( + w: f32, + h: f32, + build_shader: impl FnOnce(f32, f32) -> Option, +) -> Option { + let shader = build_shader(w, h)?; + let iw = (w.ceil() as i32).max(1); + let ih = (h.ceil() as i32).max(1); + let info = skia_safe::ImageInfo::new_n32_premul((iw, ih), None); + let mut surface = skia_safe::surfaces::raster(&info, None, None)?; + let canvas = surface.canvas(); + let mut paint = Paint::default(); + paint.set_shader(shader); + canvas.draw_rect(Rect::from_wh(w, h), &paint); + surface.image_snapshot().into() +} + // ─── 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; } @@ -241,26 +330,25 @@ 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) => { + if let Some(image) = resolve_style_image(style_image, w, h, images) { + 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); + } + // TODO: background-size, background-position, background-repeat + canvas.draw_image_rect( + &image, + Some((&src_rect, skia_safe::canvas::SrcRectConstraint::Fast)), + rect, + &paint, + ); + canvas.restore(); } + continue; } } @@ -274,6 +362,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}; @@ -362,9 +526,259 @@ fn build_conic_gradient_shader(grad: &ConicGradient, w: f32, h: f32) -> Option bool { + // Border-image area: border-box expanded by outset + let area_x = -bi.outset.left; + let area_y = -bi.outset.top; + let area_w = w + bi.outset.left + bi.outset.right; + let area_h = h + bi.outset.top + bi.outset.bottom; + + // Resolve the source image — uniform path for url() and gradient() + let image = match resolve_style_image(&bi.source, area_w, area_h, images) { + Some(img) => img, + None => return false, + }; + + let img_w = image.width() as f32; + let img_h = image.height() as f32; + if img_w <= 0.0 || img_h <= 0.0 { + return false; + } + + // Rendering widths: border-image-width or fall back to border-width + let rw = bi.width.unwrap_or(crate::cg::prelude::EdgeInsets { + top: style.border.top.width, + right: style.border.right.width, + bottom: style.border.bottom.width, + left: style.border.left.width, + }); + + // Slice offsets in source image coordinates (clamp to image dimensions) + let st = bi.slice.top.min(img_h); + let sr = bi.slice.right.min(img_w); + let sb = bi.slice.bottom.min(img_h); + let sl = bi.slice.left.min(img_w); + + let paint = Paint::default(); + + canvas.save(); + canvas.translate((area_x, area_y)); + + // ── Draw 4 corners (always scaled to fit) ── + // Top-left + if sl > 0.0 && st > 0.0 && rw.left > 0.0 && rw.top > 0.0 { + let src = Rect::from_xywh(0.0, 0.0, sl, st); + let dst = Rect::from_xywh(0.0, 0.0, rw.left, rw.top); + canvas.draw_image_rect( + &image, + Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } + // Top-right + if sr > 0.0 && st > 0.0 && rw.right > 0.0 && rw.top > 0.0 { + let src = Rect::from_xywh(img_w - sr, 0.0, sr, st); + let dst = Rect::from_xywh(area_w - rw.right, 0.0, rw.right, rw.top); + canvas.draw_image_rect( + &image, + Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } + // Bottom-left + if sl > 0.0 && sb > 0.0 && rw.left > 0.0 && rw.bottom > 0.0 { + let src = Rect::from_xywh(0.0, img_h - sb, sl, sb); + let dst = Rect::from_xywh(0.0, area_h - rw.bottom, rw.left, rw.bottom); + canvas.draw_image_rect( + &image, + Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } + // Bottom-right + if sr > 0.0 && sb > 0.0 && rw.right > 0.0 && rw.bottom > 0.0 { + let src = Rect::from_xywh(img_w - sr, img_h - sb, sr, sb); + let dst = Rect::from_xywh(area_w - rw.right, area_h - rw.bottom, rw.right, rw.bottom); + canvas.draw_image_rect( + &image, + Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } + + // Edge source regions + let edge_src_w = (img_w - sl - sr).max(0.0); + let edge_src_h = (img_h - st - sb).max(0.0); + // Edge destination regions + let edge_dst_w = (area_w - rw.left - rw.right).max(0.0); + let edge_dst_h = (area_h - rw.top - rw.bottom).max(0.0); + + // ── Draw 4 edges ── + // Top edge + if edge_src_w > 0.0 && st > 0.0 && edge_dst_w > 0.0 && rw.top > 0.0 { + let src = Rect::from_xywh(sl, 0.0, edge_src_w, st); + let dst = Rect::from_xywh(rw.left, 0.0, edge_dst_w, rw.top); + paint_edge_region(canvas, &image, src, dst, bi.repeat_x, true, &paint); + } + // Bottom edge + if edge_src_w > 0.0 && sb > 0.0 && edge_dst_w > 0.0 && rw.bottom > 0.0 { + let src = Rect::from_xywh(sl, img_h - sb, edge_src_w, sb); + let dst = Rect::from_xywh(rw.left, area_h - rw.bottom, edge_dst_w, rw.bottom); + paint_edge_region(canvas, &image, src, dst, bi.repeat_x, true, &paint); + } + // Left edge + if edge_src_h > 0.0 && sl > 0.0 && edge_dst_h > 0.0 && rw.left > 0.0 { + let src = Rect::from_xywh(0.0, st, sl, edge_src_h); + let dst = Rect::from_xywh(0.0, rw.top, rw.left, edge_dst_h); + paint_edge_region(canvas, &image, src, dst, bi.repeat_y, false, &paint); + } + // Right edge + if edge_src_h > 0.0 && sr > 0.0 && edge_dst_h > 0.0 && rw.right > 0.0 { + let src = Rect::from_xywh(img_w - sr, st, sr, edge_src_h); + let dst = Rect::from_xywh(area_w - rw.right, rw.top, rw.right, edge_dst_h); + paint_edge_region(canvas, &image, src, dst, bi.repeat_y, false, &paint); + } + + // ── Draw center (only if fill is set) ── + if bi.fill && edge_src_w > 0.0 && edge_src_h > 0.0 && edge_dst_w > 0.0 && edge_dst_h > 0.0 { + let src = Rect::from_xywh(sl, st, edge_src_w, edge_src_h); + let dst = Rect::from_xywh(rw.left, rw.top, edge_dst_w, edge_dst_h); + canvas.draw_image_rect( + &image, + Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } + + canvas.restore(); + true +} + +/// Paint a single edge region of a border-image with the specified repeat mode. +/// +/// `is_horizontal` — true for top/bottom edges (tile along x-axis), +/// false for left/right edges (tile along y-axis). +fn paint_edge_region( + canvas: &Canvas, + image: &skia_safe::Image, + src: Rect, + dst: Rect, + repeat: types::BorderImageRepeat, + is_horizontal: bool, + paint: &Paint, +) { + match repeat { + types::BorderImageRepeat::Stretch => { + canvas.draw_image_rect( + image, + Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), + dst, + paint, + ); + } + types::BorderImageRepeat::Repeat + | types::BorderImageRepeat::Round + | types::BorderImageRepeat::Space => { + // Tile the source slice along the tiling axis. + // For Round: adjust tile size so tiles fit exactly. + // For Space: add uniform gaps between tiles. + let (tile_natural, dst_extent) = if is_horizontal { + (src.width() * (dst.height() / src.height()), dst.width()) + } else { + (src.height() * (dst.width() / src.width()), dst.height()) + }; + + if tile_natural <= 0.0 || dst_extent <= 0.0 { + return; + } + + let (tile_size, gap) = match repeat { + types::BorderImageRepeat::Round => { + let n = (dst_extent / tile_natural).round().max(1.0); + (dst_extent / n, 0.0) + } + types::BorderImageRepeat::Space => { + let n = (dst_extent / tile_natural).floor(); + if n <= 1.0 { + (tile_natural, 0.0) + } else { + let total_gap = dst_extent - n * tile_natural; + (tile_natural, total_gap / (n - 1.0)) + } + } + _ => (tile_natural, 0.0), // Repeat: natural size, no gap + }; + + canvas.save(); + canvas.clip_rect(dst, ClipOp::Intersect, false); + + let mut offset = 0.0; + while offset < dst_extent { + let tile_dst = if is_horizontal { + Rect::from_xywh(dst.left + offset, dst.top, tile_size, dst.height()) + } else { + Rect::from_xywh(dst.left, dst.top + offset, dst.width(), tile_size) + }; + canvas.draw_image_rect( + image, + Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), + tile_dst, + paint, + ); + offset += tile_size + gap; + } + + canvas.restore(); + } + } +} + +// ─── Border painting (Chromium: BoxBorderPainter::PaintBorder) ──────�� + +fn paint_borders( + canvas: &Canvas, + style: &StyledElement, + w: f32, + h: f32, + images: &dyn ImageProvider, +) { + // Per CSS spec, border-image replaces normal borders when set. + if let Some(ref bi) = style.border_image { + if paint_border_image(canvas, bi, style, w, h, images) { + return; + } + // Image not available — fall through to normal border painting + } -fn paint_borders(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) { let b = &style.border; if b.top.width > 0.0 && b.top.style != types::BorderStyle::None { diff --git a/crates/grida-canvas/src/htmlcss/style.rs b/crates/grida-canvas/src/htmlcss/style.rs index 6eb4168901..dbbe32747e 100644 --- a/crates/grida-canvas/src/htmlcss/style.rs +++ b/crates/grida-canvas/src/htmlcss/style.rs @@ -57,7 +57,12 @@ 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 + + // ── Border Image (Chromium: NinePieceImage) ── + /// CSS `border-image` — replaces normal borders with a 9-slice image. + /// `None` when `border-image-source` is not set or is `none`. + pub border_image: Option, // ── Text / Font (StyleInheritedData — inherited through tree) ── pub color: CGColor, @@ -122,6 +127,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, } @@ -138,6 +148,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 @@ -212,6 +245,30 @@ pub struct BorderBox { pub left: BorderSide, } +/// CSS `border-image` resolved properties (Chromium: NinePieceImage). +/// +/// Replaces normal CSS borders with a 9-slice image. The source image is +/// divided into 9 regions by `slice` offsets, then each region is drawn +/// into the corresponding part of the element's border area. +#[derive(Debug, Clone)] +pub struct BorderImage { + /// Image source — url() or gradient. + pub source: StyleImage, + /// Slice offsets (top, right, bottom, left) in source image coordinates. + /// Defines how the source image is divided into 9 regions. + pub slice: EdgeInsets, + /// Whether to paint the center region (CSS `fill` keyword). + pub fill: bool, + /// Border-image rendering widths. `None` = use element's border-width. + pub width: Option, + /// Outset: extends the border-image area beyond the border box. + pub outset: EdgeInsets, + /// Repeat mode for horizontal edges (top/bottom). + pub repeat_x: super::types::BorderImageRepeat, + /// Repeat mode for vertical edges (left/right). + pub repeat_y: super::types::BorderImageRepeat, +} + /// CSS `outline` — uniform stroke painted on top of all content. /// /// Unlike `border`, outline is always uniform (no per-side control), @@ -326,15 +383,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 { @@ -573,6 +655,7 @@ impl Default for StyledElement { border: BorderBox::default(), background: Vec::new(), border_radius: CornerRadii::default(), + border_image: None, color: CGColor::BLACK, font: FontProps::default(), outline: Outline::default(), @@ -608,6 +691,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..765a76fd86 100644 --- a/crates/grida-canvas/src/htmlcss/types.rs +++ b/crates/grida-canvas/src/htmlcss/types.rs @@ -280,6 +280,70 @@ pub enum GridPlacement { Span(u16), } +// ─── Border image types ──────────────────────────────────────────── + +/// CSS `border-image-repeat` keyword (per axis). +/// +/// Controls how edge and center slices of a border-image are tiled +/// to fill their respective regions. +/// Chromium: `BorderImageRepeatKeyword` in `NinePieceImage`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BorderImageRepeat { + /// Scale the slice to fill the region (default). + #[default] + Stretch, + /// Tile the slice; clip if the last tile doesn't fit. + Repeat, + /// Tile the slice; scale tiles so the last one fits exactly. + Round, + /// Tile the slice with uniform spacing; no scaling. + Space, +} + +// ─── Replaced element types ──────────────────────────────────────── + +/// CSS `object-fit` for replaced elements (``, `