From e27a3bc5b07e61a385f5da2e13efaefe37584f76 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:29:57 +0000 Subject: [PATCH] render: object-fit, transform translate/scale, inline-svg rasterization object-fit: contain/cover/scale-down/none for so an image whose aspect ratio differs from its box is fit (letterbox/crop) instead of stretched. transform: parse translate()/translateX/translateY (and scale); apply translate as a paint-time offset to the element and its subtree, so translate(-50%,-50%) centering and off-screen skip-links land correctly. inline-svg: serialize and rasterize inline subtrees via resvg so icon sprites and brand logos paint instead of showing blank. --- crates/obscura-render/src/inline.rs | 12 +- crates/obscura-render/src/lib.rs | 37 ++ crates/obscura-render/src/paint.rs | 577 ++++++++++++++++++++++++++-- crates/obscura-render/src/style.rs | 125 ++++++ 4 files changed, 721 insertions(+), 30 deletions(-) diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index ef648f9..5c14d72 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -416,12 +416,16 @@ pub fn content_width(rect: &Rect, style: &LayoutStyle) -> f32 { impl TextEngine { /// Rasterize inline context `idx` into `pixmap`, honoring its clip. Uses /// cosmic-text's swash-backed rasterizer (anti-aliased, per-glyph color - /// from the span attributes). - pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap) { + /// from the span attributes). `offset` is the accumulated `transform: + /// translate()` of the container's ancestors, shifting both the glyph + /// origin and the clip so shaped text moves with a transformed box. + pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap, offset: (f32, f32)) { let TextEngine { font_system, swash, items } = self; let Some(item) = items.get_mut(idx) else { return }; - let (ox, oy) = item.origin; - let clip = item.clip; + let (ox, oy) = (item.origin.0 + offset.0, item.origin.1 + offset.1); + let clip = item + .clip + .map(|c| Rect { x: c.x + offset.0, y: c.y + offset.1, width: c.width, height: c.height }); let pw = pixmap.width() as i32; let ph = pixmap.height() as i32; let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height)); diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 560f2c6..fbdca1f 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -345,6 +345,27 @@ pub struct LayoutStyle { /// shaping (we embed the DejaVu Sans oblique/bold-oblique faces). `None` /// means inherit. pub font_style_italic: Option, + + /// `object-fit` for a replaced element (``). Controls how the decoded + /// image is scaled into the element's box when their aspect ratios differ; + /// `Fill` (default) stretches to the box, the rest preserve aspect ratio. + /// Only consulted in the image paint path. + pub object_fit: ObjectFit, + /// `transform: translate(x[, y])` / `translateX` / `translateY`, stored + /// unresolved as (dx, dy). Applied at paint time as an offset to the + /// element's own box AND its whole descendant subtree; percentages resolve + /// against the element's own border-box size then. This is what makes the + /// canonical `translate(-50%,-50%)` centering of an absolutely-positioned + /// box land in the right place, and moves a `translate(-9999px,0)` + /// off-screen skip-link out of view instead of painting it on-screen. Not + /// inherited (transform is a non-inherited property). + pub transform_translate: Option<(Dimension, Dimension)>, + /// `transform: scale(sx[, sy])` / `scaleX` / `scaleY`, captured as (sx, sy). + /// Parsed and stored so a value that mixes scale with translate still yields + /// its translate part; scale is not yet folded into paint geometry (doing so + /// correctly would require scaling every descendant's size and text, outside + /// the paint-time translate-offset model used here). + pub transform_scale: Option<(f32, f32)>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -353,6 +374,22 @@ pub enum Float { Right, } +/// `object-fit` for replaced elements (``): how the image's intrinsic +/// content is scaled into its box when their aspect ratios differ. `Fill` (the +/// default) stretches to the whole box; the others preserve the image's aspect +/// ratio, either letterboxing inside the box (`Contain`), cropping to cover it +/// (`Cover`), or using the intrinsic size (`None`, or `ScaleDown` which is +/// `Contain` capped at the intrinsic size so it never upscales). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ObjectFit { + #[default] + Fill, + Contain, + Cover, + ScaleDown, + None, +} + /// `line-height`: `normal` (a font-relative default), a unitless multiple of /// font-size, or an absolute pixel length. #[derive(Debug, Clone, Copy, PartialEq)] diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 4d4dd85..26880af 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -34,15 +34,28 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // each URL is still fetched at most once. let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache); let mut laid = layout_dom_with_images(tree, viewport, &intrinsic); + // Nodes that live inside an inline `` we rasterized as one document; + // their painting is owned by that raster, so they are skipped in both the + // box/text loop below and the inline-formatting loop after it (an svg + // `` element must not also paint its glyphs on top of the raster). + let mut svg_subtree_skip: std::collections::HashSet = std::collections::HashSet::new(); + // Whether any element carries a `transform: translate()`. When none does + // (the overwhelmingly common case), every node's accumulated offset is + // zero, so skip the per-node ancestor walk entirely and keep the paint + // path free of any added cost. + let any_transform = laid.styles.values().any(|s| s.transform_translate.is_some()); // Tree order so later elements paint over earlier ones (normal flow). for nid in tree.descendants(tree.document()) { + if svg_subtree_skip.contains(&nid) { + continue; + } let node = match tree.get_node(nid) { Some(n) => n, None => continue, }; - + if node.is_text() { - paint_text_node(tree, nid, &laid, &mut pixmap); + paint_text_node(tree, nid, &laid, &mut pixmap, any_transform); continue; } @@ -51,7 +64,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - None => continue, }; let rect = match laid.rects.get(&nid) { - Some(r) => r, + Some(r) => *r, None => continue, }; @@ -64,17 +77,33 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - continue; } + // A `transform: translate()` on this element or any ancestor offsets + // this element's whole painted box (and, applied per node, its whole + // subtree). Shift the border box and the inherited clip by the same + // accumulated vector so everything painted for this node moves together. + let (ox, oy) = if any_transform { + accumulated_translate(tree, nid, &laid) + } else { + (0.0, 0.0) + }; + let rect = crate::Rect { x: rect.x + ox, y: rect.y + oy, width: rect.width, height: rect.height }; + // Ancestor `overflow: hidden` clip, if any. Skip painting entirely // once the box has no visible overlap with it (this is what makes the // ubiquitous 1x1 clipped "visually hidden" accessibility pattern // actually invisible instead of painting text wherever it lands). - let clip = laid.clip_rects.get(&nid).copied().flatten(); + let clip = laid + .clip_rects + .get(&nid) + .copied() + .flatten() + .map(|c| crate::Rect { x: c.x + ox, y: c.y + oy, width: c.width, height: c.height }); let visible_rect = match clip { Some(c) => match rect.intersect(&c) { Some(r) => r, None => continue, }, - None => *rect, + None => rect, }; let box_rect = match Rect::from_xywh(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height) { Some(r) => r, @@ -129,14 +158,16 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - height: ih, } } - None => *rect, + None => rect, }; let img_rect = match clip { Some(c) => img_rect.intersect(&c), None => Some(img_rect), }; if let Some(img_rect) = img_rect { - paint_image(bg_url, base_url, &img_rect, &mut pixmap, &mut image_cache); + // object-fit does not apply to background images (that is + // background-size, handled above); paint the layer as-is. + paint_image(bg_url, base_url, &img_rect, crate::ObjectFit::Fill, &mut pixmap, &mut image_cache); } } @@ -191,7 +222,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - if name.local.as_ref() == "img" { if let Some(src) = resolve_img_url(tree, nid) { - let painted = paint_image(&src, base_url, &rect, &mut pixmap, &mut image_cache); + let painted = paint_image(&src, base_url, &rect, style.object_fit, &mut pixmap, &mut image_cache); // Fall back to alt text only when the image itself did not paint. if !painted { // A fetch/decode failure paints a neutral grey box (like a @@ -213,6 +244,31 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - } } + // Inline `...`: serialize the whole subtree back to one + // standalone SVG document and rasterize it as a unit, so a + // `` resolves against the ``/`` in the + // same svg. The raster owns the subtree, so its DOM children are not + // painted individually (they are added to `svg_subtree_skip`). The svg + // is drawn at its full border-box size (undistorted) and clipped to the + // overflow-visible region. + if name.local.as_ref() == "svg" { + let markup = serialize_svg(tree, nid); + if let Some(content) = render_svg(markup.as_bytes(), rect.width as u32, rect.height as u32) { + let mask = clip.and_then(|_| rect_clip_mask(pixmap.width(), pixmap.height(), &visible_rect)); + pixmap.draw_pixmap( + rect.x as i32, + rect.y as i32, + content.as_ref(), + &tiny_skia::PixmapPaint::default(), + Transform::identity(), + mask.as_ref(), + ); + } + for child in tree.descendants(nid) { + svg_subtree_skip.insert(child); + } + } + // List-item marker (bullet or number), drawn in the indent to the left // of the item's content box. `list_style` is inherited and resolved, // so `None` (e.g. a nav `