diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 1ed0a83..781fa22 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -38,9 +38,15 @@ pub struct DomLayout { pub rects: HashMap, pub styles: HashMap, /// The clip rect inherited from ancestor `overflow: hidden` boxes, keyed - /// per node. `None` means unclipped. Does not include the node's own - /// overflow (that only clips its children, not itself). + /// per node, in SCREEN space (the clip owner's box shifted by the owner's + /// accumulated translate; see `resolve_clip_rects`). `None` means + /// unclipped. Does not include the node's own overflow (that only clips + /// its children, not itself). pub clip_rects: HashMap>, + /// Accumulated `transform: translate()` per node (own + ancestors), in CSS + /// px. Only nodes with a non-zero accumulation are present; paint shifts + /// each box by this to reach screen space. + pub translates: HashMap, /// Per-word geometry for text nodes: a text DOM node lays out as one /// taffy leaf per word (see `build_text_words`), each wrapping /// independently within its container, so its rendered content is a list @@ -55,6 +61,23 @@ pub struct DomLayout { pub text_engine: crate::inline::TextEngine, #[cfg(feature = "paint")] pub ifc_items: HashMap, + /// Anonymous inline-run contexts: a mixed block's consecutive inline + /// children folded to one shaped leaf each (see `build_mixed_block`). + /// Keyed by the parent block's `NodeId`, in document order; the leaves + /// have no DOM node of their own. + #[cfg(feature = "paint")] + pub run_ifc_items: HashMap>, +} + +/// Registry of cosmic-text inline formatting contexts created during the +/// taffy-tree build: `whole` holds single-leaf containers keyed by the +/// container's own DOM id, `runs` holds anonymous inline-run leaves keyed by +/// the parent block's DOM id (those leaves have no DOM node, so their final +/// rects are captured separately; see `compute_absolute_rects`). +#[derive(Default)] +struct IfcRegistry { + whole: HashMap, + runs: HashMap>, } /// Walk the tree top-down accumulating the clip rect imposed by ancestor @@ -63,26 +86,66 @@ pub struct DomLayout { /// "visually hidden but accessible" pattern: a 1x1 absolutely-positioned box /// with clipped overflow) actually hide its contents instead of letting text /// paint wherever the box's static position happens to land. +/// Clips are stored in SCREEN space: the clip owner's border box offset by the +/// owner's own accumulated `transform: translate()`. A clip belongs to its +/// owner's coordinate space, not the painted descendant's; shifting an +/// inherited clip by the descendant's translate (the old behavior) let a +/// carousel track's `translateX` drag the viewport's clip along with every +/// slide, so all slides stayed visible inside their own shifted clip. +/// +/// The same walk records each node's accumulated translate in `translates` +/// (one pass here instead of an ancestor walk per painted node). fn resolve_clip_rects( tree: &DomTree, id: NodeId, inherited: Option, + tx: f32, + ty: f32, rects: &HashMap, styles: &HashMap, clip_rects: &mut HashMap>, + translates: &mut HashMap, ) { clip_rects.insert(id, inherited); + // This node's own translate joins the accumulation for its box and its + // whole subtree (percentages resolve against its own border box). + let (tx, ty) = match (styles.get(&id).and_then(|s| s.transform_translate), rects.get(&id)) { + (Some((dx, dy)), Some(rect)) => (tx + resolve_translate(dx, rect.width), ty + resolve_translate(dy, rect.height)), + (Some((dx, dy)), None) => (tx + resolve_translate(dx, 0.0), ty + resolve_translate(dy, 0.0)), + _ => (tx, ty), + }; + if tx != 0.0 || ty != 0.0 { + translates.insert(id, (tx, ty)); + } let next = match (styles.get(&id), rects.get(&id)) { (Some(style), Some(rect)) if style.overflow_hidden => { + let own = Rect { x: rect.x + tx, y: rect.y + ty, width: rect.width, height: rect.height }; Some(match inherited { - Some(clip) => clip.intersect(rect).unwrap_or(Rect::default()), - None => *rect, + Some(clip) => clip.intersect(&own).unwrap_or(Rect::default()), + None => own, }) } _ => inherited, }; for cid in tree.children(id) { - resolve_clip_rects(tree, cid, next, rects, styles, clip_rects); + resolve_clip_rects(tree, cid, next, tx, ty, rects, styles, clip_rects, translates); + } +} + +/// Resolve one `transform: translate()` component to px: a length passes +/// through, a percentage is taken against `basis` (the element's own +/// border-box extent on that axis). Font/viewport-relative leftovers fall +/// back to a coarse px value (translate rarely uses them). +pub(crate) fn resolve_translate(d: crate::Dimension, basis: f32) -> f32 { + match d { + crate::Dimension::Px(px) => px, + crate::Dimension::Percent(p) => p * basis, + crate::Dimension::Em(v) | crate::Dimension::Rem(v) => v * 16.0, + crate::Dimension::Vw(v) + | crate::Dimension::Vh(v) + | crate::Dimension::Vmin(v) + | crate::Dimension::Vmax(v) => v, + crate::Dimension::Auto => 0.0, } } @@ -291,7 +354,7 @@ pub fn layout_dom_with_images( let mut id_map: HashMap = HashMap::new(); let mut words: HashMap = HashMap::new(); let mut engine = crate::inline::TextEngine::new(); - let mut ifc_items: HashMap = HashMap::new(); + let mut ifc_items = IfcRegistry::default(); // The document node itself is not an element; lay out from the first // element descendant (the root). @@ -302,6 +365,9 @@ pub fn layout_dom_with_images( let mut rects = HashMap::new(); let mut text_runs = HashMap::new(); + // Final absolute rects of anonymous inline-run leaves, keyed by the + // engine item index (they have no DOM id to key `rects` by). + let mut anon_rects: HashMap = HashMap::new(); if let Some(root_id) = root { // Top-down inheritance of the properties CSS inherits by default. #[derive(Clone)] @@ -746,20 +812,21 @@ pub fn layout_dom_with_images( { let _ = taffy_tree.compute_layout(taffy_root, available); } - compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &words, &mut rects, &mut text_runs); + compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &words, &mut rects, &mut text_runs, &mut anon_rects); synthesize_row_rects(tree, &mut rects); } } let mut clip_rects = HashMap::new(); + let mut translates = HashMap::new(); if let Some(root_id) = root { - resolve_clip_rects(tree, root_id, None, &rects, &styles, &mut clip_rects); + resolve_clip_rects(tree, root_id, None, 0.0, 0.0, &rects, &styles, &mut clip_rects, &mut translates); } // Pin each shaped inline context to its final content-box origin/width now // that layout is done, so paint draws the same line breaks it was sized for. #[cfg(feature = "paint")] - for (nid, &idx) in &ifc_items { + for (nid, &idx) in &ifc_items.whole { if let (Some(rect), Some(style)) = (rects.get(nid), styles.get(nid)) { let origin = crate::inline::content_origin(rect, style); let cw = crate::inline::content_width(rect, style); @@ -767,11 +834,15 @@ pub fn layout_dom_with_images( // `overflow: hidden` on the container clips it (this is what keeps // the 1x1 "visually hidden" skip-link box from painting its text, // now that the text is one leaf rather than clipped word boxes). + // Clips live in screen space; the container's own box joins the + // chain shifted by its accumulated translate. let inherited = clip_rects.get(nid).copied().flatten(); let clip = if style.overflow_hidden { + let (tx, ty) = translates.get(nid).copied().unwrap_or((0.0, 0.0)); + let own = crate::Rect { x: rect.x + tx, y: rect.y + ty, width: rect.width, height: rect.height }; Some(match inherited { - Some(c) => c.intersect(rect).unwrap_or(crate::Rect::default()), - None => *rect, + Some(c) => c.intersect(&own).unwrap_or(crate::Rect::default()), + None => own, }) } else { inherited @@ -779,16 +850,42 @@ pub fn layout_dom_with_images( engine.finalize(idx, origin, cw, clip); } } + // Anonymous run leaves have no DOM node and no border/padding of their + // own: the leaf rect IS the content box. Clipping comes from the parent + // block (its inherited chain, plus its own overflow like any child). + #[cfg(feature = "paint")] + for (parent, items) in &ifc_items.runs { + let inherited = clip_rects.get(parent).copied().flatten(); + let clip = match (styles.get(parent), rects.get(parent)) { + (Some(style), Some(prect)) if style.overflow_hidden => { + let (tx, ty) = translates.get(parent).copied().unwrap_or((0.0, 0.0)); + let own = crate::Rect { x: prect.x + tx, y: prect.y + ty, width: prect.width, height: prect.height }; + Some(match inherited { + Some(c) => c.intersect(&own).unwrap_or(crate::Rect::default()), + None => own, + }) + } + _ => inherited, + }; + for &idx in items { + if let Some(rect) = anon_rects.get(&idx) { + engine.finalize(idx, (rect.x, rect.y), rect.width, clip); + } + } + } DomLayout { rects, styles, clip_rects, + translates, text_runs, #[cfg(feature = "paint")] text_engine: engine, #[cfg(feature = "paint")] - ifc_items, + ifc_items: ifc_items.whole, + #[cfg(feature = "paint")] + run_ifc_items: ifc_items.runs, } } @@ -996,6 +1093,7 @@ fn compute_absolute_rects( words: &HashMap, rects: &mut HashMap, text_runs: &mut HashMap>, + anon_rects: &mut HashMap, ) { if let Ok(layout) = taffy_tree.layout(taffy_id) { let x = abs_x + layout.location.x; @@ -1004,6 +1102,11 @@ fn compute_absolute_rects( if let Some(dom_id) = id_map.get(&taffy_id) { rects.insert(*dom_id, rect); + } else if let Some(&item) = taffy_tree.get_node_context(taffy_id) { + // A taffy leaf with an engine-item context but no DOM id is an + // anonymous inline-run leaf (see `build_mixed_block`); record its + // final rect by item index so the finalize pass can pin it. + anon_rects.insert(item, rect); } // A word leaf's dom_id is its owning text node, shared by every other // word from the same node, so this appends rather than overwrites. @@ -1013,7 +1116,7 @@ fn compute_absolute_rects( if let Ok(children) = taffy_tree.children(taffy_id) { for child_id in children { - compute_absolute_rects(taffy_tree, child_id, x, y, id_map, words, rects, text_runs); + compute_absolute_rects(taffy_tree, child_id, x, y, id_map, words, rects, text_runs, anon_rects); } } } @@ -1036,7 +1139,13 @@ fn has_inline_content(tree: &DomTree, id: NodeId, styles: &HashMap, words: &mut HashMap, engine: &mut crate::inline::TextEngine, - ifc_items: &mut HashMap, + ifc_items: &mut IfcRegistry, styles: &HashMap, ) -> Vec { let is_text = tree @@ -1341,7 +1450,7 @@ fn build_table( id_map: &mut HashMap, words: &mut HashMap, engine: &mut crate::inline::TextEngine, - ifc_items: &mut HashMap, + ifc_items: &mut IfcRegistry, styles: &HashMap, ) -> Option { let style = styles.get(&id)?; @@ -1473,7 +1582,7 @@ fn build( id_map: &mut HashMap, words: &mut HashMap, engine: &mut crate::inline::TextEngine, - ifc_items: &mut HashMap, + ifc_items: &mut IfcRegistry, styles: &HashMap, ) -> Option { let node = tree.get_node(id)?; @@ -1504,7 +1613,7 @@ fn build( if let Some(item) = engine.try_build(tree, id, styles) { let leaf = taffy_tree.new_leaf_with_context(taffy_style, item).ok()?; id_map.insert(leaf, id); - ifc_items.insert(id, item); + ifc_items.whole.insert(id, item); return Some(leaf); } @@ -1529,6 +1638,35 @@ fn build( || (style.display == crate::Display::Flex && style.flex_direction == Some(taffy::FlexDirection::Column)); let has_inline_ish_content = has_inline_content(tree, id, styles) || style.before_content.is_some() || style.after_content.is_some(); + + let mut dom_children = tree.children(id); + // In a grid formatting context, whitespace-only text between items does not + // generate an anonymous grid item (CSS Grid). taffy 0.12 places each stray + // whitespace text node in its own cell, which offsets every real item, so a + // Tailwind `grid-cols-3` block with newlines between its children lays out + // diagonally instead of in one row (this collapsed the whole modern + // framework cluster after the taffy upgrade). Drop them before building. + if style.display == crate::Display::Grid { + dom_children.retain(|&cid| { + tree.get_node(cid).map_or(false, |n| n.is_element()) || !tree.text_content(cid).trim().is_empty() + }); + } + let has_float_child = dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false)); + + // A block with mixed inline + block children keeps real block layout: + // block-level children become direct block children (full available + // width, exactly what CSS block layout gives them), while each maximal + // run of consecutive inline-level siblings is wrapped in one anonymous + // block-level box that carries the inline formatting context. This is + // how real engines structure mixed content, and it replaces the old + // whole-container flex-row-wrap promotion, which collapsed contentless + // block children (e.g. a `position:relative` hero wrapper whose only + // child is out-of-flow) to width 0 and let text and blocks share lines. + // Floats still take the legacy zone path below. + if style.display == crate::Display::Block && has_inline_ish_content && !has_float_child { + return build_mixed_block(tree, id, style, taffy_style, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles); + } + if stacks_children_vertically && has_inline_ish_content { taffy_style.display = taffy::style::Display::Flex; taffy_style.flex_direction = taffy::FlexDirection::Row; @@ -1553,19 +1691,6 @@ fn build( taffy_style.align_items = Some(taffy::AlignItems::FLEX_START); } - let mut dom_children = tree.children(id); - // In a grid formatting context, whitespace-only text between items does not - // generate an anonymous grid item (CSS Grid). taffy 0.12 places each stray - // whitespace text node in its own cell, which offsets every real item, so a - // Tailwind `grid-cols-3` block with newlines between its children lays out - // diagonally instead of in one row (this collapsed the whole modern - // framework cluster after the taffy upgrade). Drop them before building. - if style.display == crate::Display::Grid { - dom_children.retain(|&cid| { - tree.get_node(cid).map_or(false, |n| n.is_element()) || !tree.text_content(cid).trim().is_empty() - }); - } - let has_float_child = dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false)); let mut child_ids: Vec = if has_float_child { build_children_with_float_zone(tree, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles) } else { @@ -1589,6 +1714,202 @@ fn build( Some(taffy_id) } +/// Build a block container whose children mix inline-level and block-level +/// content, preserving real block layout for the block children. +/// +/// Structure produced (mirroring how CSS block containers actually work): +/// - block-level in-flow children stay direct children of the (taffy Block) +/// parent, so an auto width fills the containing block even when the child +/// has no in-flow content of its own; +/// - each maximal run of consecutive inline-level siblings becomes ONE +/// anonymous block-level box carrying the inline formatting context. A run +/// of pure text and foldable inline wrappers collapses to a single shaped +/// cosmic-text leaf (one taffy node for the whole run instead of one per +/// word: faster and lighter than the old whole-container promotion). Runs +/// holding atomic inline boxes (img, inline-block, ...) fall back to an +/// anonymous flex-wrap wrapper around the run's boxes; +/// - out-of-flow (absolutely positioned) children neither join nor break a +/// run; they are appended after the flow children so their containing +/// block is this parent, whose used width they resolve percentages against. +#[allow(clippy::too_many_arguments)] +fn build_mixed_block( + tree: &DomTree, + id: NodeId, + style: &crate::LayoutStyle, + mut taffy_style: taffy::Style, + dom_children: &[NodeId], + taffy_tree: &mut TaffyTree, + id_map: &mut HashMap, + words: &mut HashMap, + engine: &mut crate::inline::TextEngine, + ifc_items: &mut IfcRegistry, + styles: &HashMap, +) -> Option { + // The parent is a genuine block container. `to_taffy_style` may have + // promoted it to a flex column for `text-align` (taffy block layout has + // no align-items); undo that: text-align moves line content, never + // block children, so it is applied to the runs below instead. + taffy_style.display = taffy::style::Display::Block; + + // Splice display:contents children and drop nothing else, so runs + // partition over the child list exactly as CSS sees it. + let mut flat: Vec = Vec::new(); + flatten_contents_children(tree, dom_children, styles, &mut flat); + + enum Seg { + Run(Vec), + Block(NodeId), + } + let mut segs: Vec = Vec::new(); + let mut out_of_flow: Vec = Vec::new(); + for &cid in &flat { + let Some(node) = tree.get_node(cid) else { continue }; + let is_text = matches!(node.data, obscura_dom::tree::NodeData::Text { .. }); + let inline_level = if is_text { + true + } else if let Some(s) = styles.get(&cid) { + if s.display == crate::Display::None { + continue; + } + if matches!(s.position, Some(taffy::Position::Absolute)) { + out_of_flow.push(cid); + continue; + } + s.display == crate::Display::Inline + } else { + false + }; + if inline_level { + match segs.last_mut() { + Some(Seg::Run(run)) => run.push(cid), + _ => segs.push(Seg::Run(vec![cid])), + } + } else { + segs.push(Seg::Block(cid)); + } + } + + // ::before joins the first inline run and ::after the last (a list + // marker must share its item text's lines); when the adjacent segment + // is a block, the pseudo content gets its own anonymous run instead. + let before_leaves = style.before_content.as_ref().map(|c| build_pseudo_content(id, c, style, taffy_tree, words)).unwrap_or_default(); + let after_leaves = style.after_content.as_ref().map(|c| build_pseudo_content(id, c, style, taffy_tree, words)).unwrap_or_default(); + let mut before_pending = !before_leaves.is_empty(); + let mut after_pending = !after_leaves.is_empty(); + + let n_segs = segs.len(); + let mut child_ids: Vec = Vec::new(); + for (i, seg) in segs.into_iter().enumerate() { + match seg { + Seg::Block(cid) => { + child_ids.extend(build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)); + } + Seg::Run(run) => { + let join_before = before_pending && i == 0; + let join_after = after_pending && i + 1 == n_segs; + // Fast path: the whole run folds to one shaped leaf, unless + // pseudo-content word leaves must share its lines. + if !join_before && !join_after { + if let Some(item) = engine.try_build_run(tree, id, &run, styles) { + let leaf = taffy_tree.new_leaf_with_context(run_leaf_style(), item).ok()?; + ifc_items.runs.entry(id).or_default().push(item); + child_ids.push(leaf); + continue; + } + } + let mut atoms: Vec = Vec::new(); + if join_before { + atoms.extend(before_leaves.iter().copied()); + before_pending = false; + } + for &rc in &run { + atoms.extend(build_any(tree, rc, taffy_tree, id_map, words, engine, ifc_items, styles)); + } + if join_after { + atoms.extend(after_leaves.iter().copied()); + after_pending = false; + } + if atoms.is_empty() { + // Whitespace-only run between blocks: no anonymous box. + continue; + } + let wrapper = taffy_tree.new_with_children(run_wrapper_style(style), &atoms).ok()?; + child_ids.push(wrapper); + } + } + } + // Pseudo content that found no adjacent run to join. + if before_pending { + let wrapper = taffy_tree.new_with_children(run_wrapper_style(style), &before_leaves).ok()?; + child_ids.insert(0, wrapper); + } + if after_pending { + let wrapper = taffy_tree.new_with_children(run_wrapper_style(style), &after_leaves).ok()?; + child_ids.push(wrapper); + } + for cid in out_of_flow { + child_ids.extend(build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)); + } + + let taffy_id = if child_ids.is_empty() { + taffy_tree.new_leaf(taffy_style).ok()? + } else { + taffy_tree.new_with_children(taffy_style, &child_ids).ok()? + }; + id_map.insert(taffy_id, id); + Some(taffy_id) +} + +/// Expand `display: contents` wrappers so their children partition into the +/// caller's segment list as if they were direct children (CSS Display 3). +fn flatten_contents_children( + tree: &DomTree, + children: &[NodeId], + styles: &HashMap, + out: &mut Vec, +) { + for &cid in children { + let splices = styles.get(&cid).map(|s| s.display_contents && s.display != crate::Display::None).unwrap_or(false); + if splices { + let kids = tree.children(cid); + flatten_contents_children(tree, &kids, styles, out); + } else { + out.push(cid); + } + } +} + +/// Style for a single-leaf inline run: a block-level box that fills the +/// containing block's width, with height from the shaped text (measure fn). +fn run_leaf_style() -> taffy::Style { + taffy::Style { + size: taffy::Size { width: taffy::style::Dimension::percent(1.0), height: taffy::style::Dimension::auto() }, + ..Default::default() + } +} + +/// Style for an anonymous inline-run wrapper: a full-width block-level box +/// whose interior is the flex-wrap approximation of an inline formatting +/// context. The parent's `text-align` stand-in (align_items) moves the run's +/// line content via justify-content, exactly as the old whole-container +/// promotion did, but scoped to the run so sibling blocks stay full width. +fn run_wrapper_style(parent: &crate::LayoutStyle) -> taffy::Style { + let justify = match parent.align_items { + Some(taffy::AlignItems::FLEX_END) => Some(taffy::JustifyContent::FLEX_END), + Some(taffy::AlignItems::CENTER) => Some(taffy::JustifyContent::CENTER), + _ => None, + }; + taffy::Style { + display: taffy::style::Display::Flex, + flex_direction: taffy::FlexDirection::Row, + flex_wrap: taffy::FlexWrap::Wrap, + align_items: Some(taffy::AlignItems::FLEX_START), + justify_content: justify, + size: taffy::Size { width: taffy::style::Dimension::percent(1.0), height: taffy::style::Dimension::auto() }, + ..Default::default() + } +} + /// Approximate `float: left|right` without real per-line reflow (which /// taffy's block/flex/grid modes do not provide): place the float alongside /// the flow siblings that follow it, up to whichever comes first of the next @@ -1679,7 +2000,7 @@ fn build_children_with_float_zone( id_map: &mut HashMap, words: &mut HashMap, engine: &mut crate::inline::TextEngine, - ifc_items: &mut HashMap, + ifc_items: &mut IfcRegistry, styles: &HashMap, ) -> Vec { let is_float = |cid: NodeId| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false); diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index b144f98..62311b4 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -150,28 +150,55 @@ impl TextEngine { return None; } let base = styles.get(&id)?; - let base_size = base.font_size.unwrap_or(16.0); - // `-webkit-background-clip: text` on a transparent-colored element paints - // its background *through* the glyphs (gradient/solid text). When active, - // shape the glyphs in opaque white so their coverage renders, then recolor - // them from the background at paint time; otherwise transparent text stays - // transparent (and invisible), unchanged. - let clip_fill = clip_text_fill(base); - let default_color = if clip_fill.is_some() { - [255, 255, 255, 255] - } else { - base.color.unwrap_or([0, 0, 0, 255]) - }; - - let root_transform = base.text_transform.unwrap_or(TextTransform::None); - let root_underline = base.underline.unwrap_or(false); - let root_italic = base.font_style_italic.unwrap_or(false); - let root_bold = base.font_weight.as_deref() == Some("bold"); - let root_family = resolve_font_family(base.font_family.as_deref()); + let (ctx, clip_fill) = base_span_ctx(base); let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); let mut collector = Collector { last_was_space: true }; - let ctx = SpanCtx { color: default_color, bold: root_bold, italic: root_italic, underline: root_underline, transform: root_transform, family: root_family }; collect_spans(tree, id, styles, ctx, &mut spans, &mut collector); + self.push_shaped_item(base, spans, clip_fill) + } + + /// Build an inline formatting context from a *run* of consecutive + /// inline-level siblings inside `parent` (a mixed-content block whose + /// other children are block-level). The run folds to one shaped buffer + /// exactly like a whole-container IFC, using the parent's style as the + /// base. Returns `None` when any node in the run cannot fold (atomic + /// inline, replaced element, ...) or the run has no visible text; the + /// caller then falls back to the flex-wrap wrapper for that run. + pub fn try_build_run( + &mut self, + tree: &DomTree, + parent: NodeId, + run: &[NodeId], + styles: &std::collections::HashMap, + ) -> Option { + let mut has_text = false; + for &cid in run { + if !inline_child_ok(tree, cid, styles, &mut has_text) { + return None; + } + } + if !has_text { + return None; + } + let base = styles.get(&parent)?; + let (ctx, clip_fill) = base_span_ctx(base); + let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); + let mut collector = Collector { last_was_space: true }; + for &cid in run { + collect_node_spans(tree, cid, styles, ctx, &mut spans, &mut collector); + } + self.push_shaped_item(base, spans, clip_fill) + } + + /// Shared tail of [`try_build`] / [`try_build_run`]: shape the collected + /// spans into a cosmic-text buffer under `base`'s font metrics and + /// alignment, and store it as a new inline item. + fn push_shaped_item( + &mut self, + base: &LayoutStyle, + mut spans: Vec<(String, SpanAttrs)>, + clip_fill: Option<(f32, Vec<([u8; 4], Option)>)>, + ) -> Option { // Trim a single trailing space so it does not widen the last line. if let Some((text, _)) = spans.last_mut() { if text.ends_with(' ') { @@ -182,6 +209,7 @@ impl TextEngine { return None; } + let base_size = base.font_size.unwrap_or(16.0); // Real `line-height` drives vertical rhythm; a fixed ratio made // real-site prose (e.g. Wikipedia's 1.6) noticeably tighter than // Chromium. `normal` is font-relative; ~1.2 matches DejaVu closely. @@ -293,6 +321,32 @@ struct Collector { /// spans. Collapsing spans HTML's insignificant whitespace (runs of spaces, /// tabs, and newlines fold to one space; leading space at the start of the /// context is dropped) exactly as `white-space: normal` requires. +/// Root span context (and background-clip-text fill, when active) for an IFC +/// whose base style is `base`. +/// +/// `-webkit-background-clip: text` on a transparent-colored element paints +/// its background *through* the glyphs (gradient/solid text). When active, +/// shape the glyphs in opaque white so their coverage renders, then recolor +/// them from the background at paint time; otherwise transparent text stays +/// transparent (and invisible), unchanged. +fn base_span_ctx(base: &LayoutStyle) -> (SpanCtx, Option<(f32, Vec<([u8; 4], Option)>)>) { + let clip_fill = clip_text_fill(base); + let default_color = if clip_fill.is_some() { + [255, 255, 255, 255] + } else { + base.color.unwrap_or([0, 0, 0, 255]) + }; + let ctx = SpanCtx { + color: default_color, + bold: base.font_weight.as_deref() == Some("bold"), + italic: base.font_style_italic.unwrap_or(false), + underline: base.underline.unwrap_or(false), + transform: base.text_transform.unwrap_or(TextTransform::None), + family: resolve_font_family(base.font_family.as_deref()), + }; + (ctx, clip_fill) +} + fn collect_spans( tree: &DomTree, id: NodeId, @@ -302,38 +356,53 @@ fn collect_spans( c: &mut Collector, ) { for cid in tree.children(id) { - let Some(node) = tree.get_node(cid) else { continue }; - match &node.data { - obscura_dom::tree::NodeData::Text { contents } => { - let attrs = SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family }; - push_text(contents, ctx.transform, &attrs, out, c); + collect_node_spans(tree, cid, styles, ctx, out, c); + } +} + +/// Collect the spans contributed by one node (a text node's runs, or an +/// element's whole subtree with its style threaded through). Split out of +/// [`collect_spans`] so an inline *run* (a slice of siblings, not a whole +/// container) can also be folded into one shaped buffer. +fn collect_node_spans( + tree: &DomTree, + cid: NodeId, + styles: &std::collections::HashMap, + ctx: SpanCtx, + out: &mut Vec<(String, SpanAttrs)>, + c: &mut Collector, +) { + let Some(node) = tree.get_node(cid) else { return }; + match &node.data { + obscura_dom::tree::NodeData::Text { contents } => { + let attrs = SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family }; + push_text(contents, ctx.transform, &attrs, out, c); + } + _ => { + let Some(elem) = node.as_element() else { return }; + let style = styles.get(&cid); + if style.map(|s| s.display == Display::None).unwrap_or(false) { + return; } - _ => { - let Some(elem) = node.as_element() else { continue }; - let style = styles.get(&cid); - if style.map(|s| s.display == Display::None).unwrap_or(false) { - continue; - } - if elem.local.as_ref() == "br" { - out.push(("\n".to_string(), SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family })); - c.last_was_space = true; - continue; - } - let child = SpanCtx { - color: style.and_then(|s| s.color).unwrap_or(ctx.color), - bold: ctx.bold || style.map(|s| s.font_weight.as_deref() == Some("bold")).unwrap_or(false), - italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false), - // Underline propagates in: an ancestor's underline covers - // descendant text; an element only sets its own via CSS. - underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false), - transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform), - family: style - .and_then(|s| s.font_family.as_deref()) - .map(|f| resolve_font_family(Some(f))) - .unwrap_or(ctx.family), - }; - collect_spans(tree, cid, styles, child, out, c); + if elem.local.as_ref() == "br" { + out.push(("\n".to_string(), SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family })); + c.last_was_space = true; + return; } + let child = SpanCtx { + color: style.and_then(|s| s.color).unwrap_or(ctx.color), + bold: ctx.bold || style.map(|s| s.font_weight.as_deref() == Some("bold")).unwrap_or(false), + italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false), + // Underline propagates in: an ancestor's underline covers + // descendant text; an element only sets its own via CSS. + underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false), + transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform), + family: style + .and_then(|s| s.font_family.as_deref()) + .map(|f| resolve_font_family(Some(f))) + .unwrap_or(ctx.family), + }; + collect_spans(tree, cid, styles, child, out, c); } } } @@ -562,9 +631,11 @@ impl TextEngine { let TextEngine { font_system, swash, items } = self; let Some(item) = items.get_mut(idx) else { return }; 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 }); + // The glyph origin shifts by the container's accumulated translate, + // but the clip is already in screen space (owner-shifted at + // `resolve_clip_rects`) and must not move with the container, or a + // translated slide would drag its viewport's clip along with it. + let clip = item.clip; 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 a29ee63..da67d53 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -51,6 +51,17 @@ pub mod inline { pub fn try_build(&mut self, _tree: &DomTree, _id: NodeId, _styles: &HashMap) -> Option { None } + /// See `try_build`: inline runs likewise fall back to word-split + /// geometry in layout-only builds. + pub fn try_build_run( + &mut self, + _tree: &DomTree, + _parent: NodeId, + _run: &[NodeId], + _styles: &HashMap, + ) -> Option { + None + } } } diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 1fd1d80..1daa561 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -48,7 +48,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // (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) { @@ -60,7 +60,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - }; if node.is_text() { - paint_text_node(tree, nid, &laid, &mut pixmap, any_transform); + paint_text_node(tree, nid, &laid, &mut pixmap); continue; } @@ -84,25 +84,19 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // 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) - }; + // subtree). The box shifts into screen space; the inherited clip is + // ALREADY in screen space (shifted by its own owner's translate at + // `resolve_clip_rects`), so it must not move with this descendant: + // that is what lets a clip cull a slide the carousel track has + // translated out of its viewport. + let (ox, oy) = laid.translates.get(&nid).copied().unwrap_or((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() - .map(|c| crate::Rect { x: c.x + ox, y: c.y + oy, width: c.width, height: c.height }); + let clip = laid.clip_rects.get(&nid).copied().flatten(); let visible_rect = match clip { Some(c) => match rect.intersect(&c) { Some(r) => r, @@ -344,68 +338,33 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - if svg_subtree_skip.contains(&nid) { continue; } - if let Some(&idx) = laid.ifc_items.get(&nid) { - if laid.styles.get(&nid).map(|s| s.effectively_invisible).unwrap_or(false) { - continue; - } - // Shift the shaped glyphs by the same accumulated translate as the - // container's box so text under a transformed ancestor moves with - // it. Computed before the mutable `paint_item` borrow. - let off = if any_transform { - accumulated_translate(tree, nid, &laid) - } else { - (0.0, 0.0) - }; + let whole = laid.ifc_items.get(&nid).copied(); + let run_items = laid.run_ifc_items.get(&nid).cloned(); + if whole.is_none() && run_items.is_none() { + continue; + } + if laid.styles.get(&nid).map(|s| s.effectively_invisible).unwrap_or(false) { + continue; + } + // Shift the shaped glyphs by the same accumulated translate as the + // container's box so text under a transformed ancestor moves with + // it. Computed before the mutable `paint_item` borrow. + let off = laid.translates.get(&nid).copied().unwrap_or((0.0, 0.0)); + if let Some(idx) = whole { laid.text_engine.paint_item(idx, &mut pixmap, off); } + // Anonymous inline-run leaves of a mixed block (see + // `build_mixed_block`), pinned to their own boxes at finalize. + if let Some(items) = run_items { + for idx in items { + laid.text_engine.paint_item(idx, &mut pixmap, off); + } + } } Some(pixmap) } -/// Sum of `transform: translate()` offsets of `nid` and every ancestor, in CSS -/// px. A translate shifts an element's own box and its entire subtree by the -/// same vector, so a node's painted position is its normal-flow position plus -/// every translate at or above it. Percentage components resolve against each -/// contributing element's own border box (read from the laid-out rects), per -/// CSS. Nodes with no style/rect entry (e.g. text nodes) contribute nothing but -/// are still walked through so element ancestors above them count. -fn accumulated_translate( - tree: &DomTree, - nid: obscura_dom::tree::NodeId, - laid: &crate::DomLayout, -) -> (f32, f32) { - let (mut ox, mut oy) = (0.0f32, 0.0f32); - let mut cur = Some(nid); - while let Some(id) = cur { - if let (Some(style), Some(rect)) = (laid.styles.get(&id), laid.rects.get(&id)) { - if let Some((dx, dy)) = style.transform_translate { - ox += resolve_translate(dx, rect.width); - oy += resolve_translate(dy, rect.height); - } - } - cur = tree.get_node(id).and_then(|n| n.parent); - } - (ox, oy) -} - -/// Resolve one translate component to px: a length passes through, a percentage -/// is taken against `basis` (the element's own border-box extent on that axis). -/// Font/viewport-relative leftovers fall back to a coarse px value rather than -/// panicking (translate rarely uses them; px/% are the required forms). -fn resolve_translate(d: crate::Dimension, basis: f32) -> f32 { - match d { - crate::Dimension::Px(px) => px, - crate::Dimension::Percent(p) => p * basis, - crate::Dimension::Em(v) | crate::Dimension::Rem(v) => v * 16.0, - crate::Dimension::Vw(v) - | crate::Dimension::Vh(v) - | crate::Dimension::Vmin(v) - | crate::Dimension::Vmax(v) => v, - crate::Dimension::Auto => 0.0, - } -} - /// A closed rounded-rectangle path, corners approximated by quadratic curves /// (visually indistinguishable from true arcs at typical UI radii). `r` is /// clamped so it never exceeds half the shorter side. @@ -587,7 +546,7 @@ fn paint_text_node( nid: obscura_dom::tree::NodeId, laid: &crate::DomLayout, pixmap: &mut Pixmap, - any_transform: bool, + ) -> Option<()> { let runs = laid.text_runs.get(&nid)?; let node = tree.get_node(nid)?; @@ -600,18 +559,10 @@ fn paint_text_node( let fsize = style.font_size.unwrap_or(16.0); let is_bold = style.font_weight.as_deref() == Some("bold"); // A text node has no transform of its own, but any transformed element - // ancestor offsets it; the ancestor walk from the text node picks those up. - let (ox, oy) = if any_transform { - accumulated_translate(tree, nid, laid) - } else { - (0.0, 0.0) - }; - 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 }); + // ancestor offsets it (the accumulation covers text nodes too). The clip + // is already in screen space and stays put. + let (ox, oy) = laid.translates.get(&nid).copied().unwrap_or((0.0, 0.0)); + let clip = laid.clip_rects.get(&nid).copied().flatten(); for (rect, word) in runs { draw_text(pixmap, word, rect.x + ox, rect.y + oy, color, fsize, is_bold, clip); diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 4449716..361f757 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -537,7 +537,15 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { /// translate and scale parts. fn parse_transform(style: &mut LayoutStyle, value: &str) { let v = value.trim(); - if v.is_empty() || v.eq_ignore_ascii_case("none") { + if v.is_empty() { + return; + } + // `transform: none` resets any transform from a lower-priority rule (the + // carousel idiom: a class translates the track, an inline style clears it + // for the untransformed state). + if v.eq_ignore_ascii_case("none") { + style.transform_translate = None; + style.transform_scale = None; return; } let zero = crate::Dimension::Px(0.0); @@ -546,7 +554,10 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) { for (func, args) in transform_functions(v) { let parts: Vec<&str> = args.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); match func.to_ascii_lowercase().as_str() { - "translate" => { + // translate3d's z component is ignored (no perspective model); + // Swiper and similar carousels position their track exclusively + // through translate3d, so dropping it left every slide stacked. + "translate" | "translate3d" => { if let Some(a) = parts.first() { tx = Some(dimension_value(a)); }