Match inline font fragment geometry

This commit is contained in:
SGavrl committed 2026-07-31 10:02:59 +00:00
1 parent 61d61feb50
commit 3e377cef67
9 files changed
+561 -20

No files matched your search

+267 -2
View File
@@ -224,6 +224,11 @@ impl OverflowClip {
/// Per-element border boxes after layout, in viewport coordinates.
pub struct DomLayout {
pub rects: HashMap<NodeId, Rect>,
/// Per-line border-box fragments for ordinary non-replaced inline
/// elements. `rects` retains their union for `getBoundingClientRect()`;
/// this list is the source for background/border painting and
/// `getClientRects()` as wrapping support is extended.
pub inline_fragments: HashMap<NodeId, Vec<Rect>>,
pub styles: HashMap<NodeId, crate::LayoutStyle>,
/// Computed custom properties in force for each element. Values are
/// reference-counted because the overwhelmingly common case is inheritance
@@ -262,6 +267,11 @@ pub struct DomLayout {
/// have no DOM node of their own.
#[cfg(feature = "paint")]
pub run_ifc_items: HashMap<NodeId, Vec<usize>>,
/// Webfont-shaped items backing the general per-word fallback. Unlike
/// `run_ifc_items`, these remain keyed by their real DOM text node so they
/// can paint in exact tree order at each independently wrapped Taffy box.
#[cfg(feature = "paint")]
pub word_ifc_items: HashMap<NodeId, Vec<usize>>,
/// Anonymous in-flow boxes generated by `::before`/`::after`. These have
/// no DOM node of their own, but unlike the legacy text-only fast path
/// they participate in layout with their pseudo style's real box model.
@@ -1019,6 +1029,7 @@ fn sync_positioned_pseudo_percentage_padding(
struct IfcRegistry {
whole: HashMap<NodeId, usize>,
runs: HashMap<NodeId, Vec<usize>>,
word_items: HashMap<NodeId, Vec<usize>>,
generated: Vec<GeneratedBoxBuild>,
/// Specified column widths per table grid node, from `<col>` elements and
/// colspan-1 cells: `(px, percent)` per column index. Consumed by the
@@ -2035,6 +2046,7 @@ fn layout_dom_once(
.find(|id| tree.get_node(*id).map(|n| n.is_element()).unwrap_or(false));
let mut rects = HashMap::new();
let mut inline_fragments = 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).
@@ -3866,6 +3878,8 @@ fn layout_dom_once(
&generated_nodes,
&mut generated_rects,
);
inline_fragments =
synthesize_ordinary_inline_fragments(&mut rects, &styles, &engine);
synthesize_row_rects(tree, &mut rects);
}
}
@@ -3959,6 +3973,23 @@ fn layout_dom_once(
}
}
}
// General word-fallback items are shaped with the same loaded font system
// as full IFCs but retain one independently wrapped Taffy rect per token.
// Their source is a real text node, so clipping/translation follows that
// node while each item is pinned to its anonymous leaf.
#[cfg(feature = "paint")]
for (text_node, items) in &ifc_items.word_items {
let clip = clip_rects
.get(text_node)
.copied()
.flatten()
.map(|clip| clip.viewport_rect(viewport));
for &idx in items {
if let Some(rect) = anon_rects.get(&idx) {
engine.finalize(idx, (rect.x, rect.y), rect.width, clip);
}
}
}
let generated_boxes = ifc_items
.generated
@@ -3976,6 +4007,7 @@ fn layout_dom_once(
(
DomLayout {
rects,
inline_fragments,
styles,
custom_properties,
clip_rects,
@@ -3987,6 +4019,8 @@ fn layout_dom_once(
ifc_items: ifc_items.whole,
#[cfg(feature = "paint")]
run_ifc_items: ifc_items.runs,
#[cfg(feature = "paint")]
word_ifc_items: ifc_items.word_items,
generated_boxes,
},
signature,
@@ -3995,6 +4029,54 @@ fn layout_dom_once(
)
}
/// Convert Taffy's ordinary-inline line surrogate into the element's actual
/// visual fragment box.
///
/// The surrogate remains at CSS line-height so it contributes the correct
/// line advance. The fragment uses the selected face's grid-fitted ascent plus
/// descent, expanded by block-axis padding and border. Those decorations
/// protrude into the leading and never feed back into the line height.
fn synthesize_ordinary_inline_fragments(
rects: &mut HashMap<NodeId, Rect>,
styles: &HashMap<NodeId, crate::LayoutStyle>,
engine: &crate::inline::TextEngine,
) -> HashMap<NodeId, Vec<Rect>> {
let mut fragments = HashMap::new();
let mut unions = Vec::new();
for (&id, style) in styles {
if !style.ignores_used_box_sizes() {
continue;
}
let Some(flow_rect) = rects.get(&id).copied() else {
continue;
};
let font_height = engine.inline_font_box_height(style).max(0.0);
let line_height = engine.selected_line_height(style).max(0.0);
// Taffy's surrogate may be taller than this element's own strut when
// a nested inline enlarges the line. Center the element's logical
// line-height inside that allocation before removing its leading.
let logical_top = flow_rect.y + (flow_rect.height - line_height) / 2.0;
let fragment = Rect {
x: flow_rect.x,
y: logical_top + (line_height - font_height) / 2.0
- style.padding.top
- style.border.top,
width: flow_rect.width,
height: font_height
+ style.padding.top
+ style.padding.bottom
+ style.border.top
+ style.border.bottom,
};
fragments.insert(id, vec![fragment]);
unions.push((id, fragment));
}
for (id, union) in unions {
rects.insert(id, union);
}
fragments
}
fn container_snapshot(
tree: &DomTree,
layout: &DomLayout,
@@ -5736,7 +5818,15 @@ fn build_any(
.map(|n| matches!(n.data, obscura_dom::tree::NodeData::Text { .. }))
.unwrap_or(false);
if is_text {
return build_text_words(tree, id, taffy_tree, styles, words);
return build_text_words(
tree,
id,
taffy_tree,
styles,
words,
engine,
ifc_items,
);
}
// `display: contents` removes the element's own box; its children lay out as
// if they were direct children of its parent (CSS Display 3). Splice them
@@ -5975,6 +6065,8 @@ fn build_text_words(
taffy_tree: &mut TaffyTree<usize>,
styles: &HashMap<NodeId, crate::LayoutStyle>,
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
engine: &mut crate::inline::TextEngine,
ifc_items: &mut IfcRegistry,
) -> Vec<taffy::NodeId> {
let Some(node) = tree.get_node(id) else { return Vec::new() };
let obscura_dom::tree::NodeData::Text { contents } = &node.data else { return Vec::new() };
@@ -6011,8 +6103,22 @@ fn build_text_words(
letter_spacing = p_style.letter_spacing.unwrap_or(0.0);
}
}
display_text = transform_word_leaf_text(&display_text, transform);
if let Some(style) = node.parent.and_then(|parent| styles.get(&parent)) {
let shaped = build_shaped_word_leaves(
id,
&display_text,
style,
taffy_tree,
words,
engine,
ifc_items,
);
if !shaped.is_empty() {
return shaped;
}
}
display_text = transform_word_leaf_text(&display_text, transform);
build_word_leaves(
id,
&display_text,
@@ -6026,6 +6132,70 @@ fn build_text_words(
)
}
/// Webfont-aware counterpart to [`build_word_leaves`].
///
/// The general flex-wrap fallback still needs one independently placeable
/// Taffy leaf per word. Shape each visible token through the render pass's
/// retained `TextEngine`, then reuse that same item at paint time. This keeps
/// loaded faces, variable axes, optical sizing, transforms, and glyph
/// rasterization identical to the full inline-formatting-context path.
fn build_shaped_word_leaves(
source_id: NodeId,
text: &str,
style: &crate::LayoutStyle,
taffy_tree: &mut TaffyTree<usize>,
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
engine: &mut crate::inline::TextEngine,
ifc_items: &mut IfcRegistry,
) -> Vec<taffy::NodeId> {
let mut leaves = Vec::new();
for token in tokenize_with_spaces(text) {
if token.trim().is_empty() {
continue;
}
// A token normally retains one trailing collapsed space. Shape it as
// preformatted content so the item keeps that advance instead of the
// paragraph path trimming collapsible trailing whitespace.
let mut token_style = style.clone();
token_style.white_space = Some(crate::WhiteSpace::Pre);
let Some(item) = engine.push_generated_text(&token, &token_style) else {
// Returning no leaves makes the caller use the deterministic
// layout-only/static-font fallback for the whole text node.
return Vec::new();
};
let (width, height) = engine.measure_word(item);
let taffy_style = taffy::Style {
size: taffy::Size {
width: taffy::Dimension::length(width.max(0.0)),
height: taffy::Dimension::length(height.max(0.0)),
},
..Default::default()
};
let Ok(leaf) = taffy_tree.new_leaf_with_context(taffy_style, item) else {
return Vec::new();
};
words.insert(
leaf,
(
source_id,
transform_word_leaf_text(
&token,
style
.text_transform
.unwrap_or(crate::TextTransform::None),
),
),
);
ifc_items
.word_items
.entry(source_id)
.or_default()
.push(item);
leaves.push(leaf);
}
leaves
}
fn transform_word_leaf_text(text: &str, transform: crate::TextTransform) -> String {
match transform {
crate::TextTransform::None => text.to_string(),
@@ -10985,6 +11155,101 @@ mod tests {
);
}
#[test]
fn ordinary_inline_fragment_uses_font_box_while_line_keeps_line_height() {
let tree = parse_html(
r#"<style>
html,body,p { margin:0 }
p { height:auto; font:16px/40px monospace }
code {
position:relative;
font:16px/40px monospace;
padding-top:5px; padding-bottom:7px;
border-top:2px solid; border-bottom:3px solid;
margin-top:19px; margin-bottom:23px;
background:red
}
#control { margin-top:0; margin-bottom:0 }
</style>
<p id="with-margin"><code id="token">token</code></p>
<p id="without-margin"><code id="control">token</code></p>"#,
);
let laid = layout_dom(&tree, (400.0, 200.0));
let host = tree.get_element_by_id("with-margin").unwrap();
let control_host = tree.get_element_by_id("without-margin").unwrap();
let token = tree.get_element_by_id("token").unwrap();
let control = tree.get_element_by_id("control").unwrap();
let style = &laid.styles[&token];
let raw_font_height = laid.text_engine.inline_font_box_height(style);
let expected_fragment_height = raw_font_height + 5.0 + 7.0 + 2.0 + 3.0;
assert_eq!(
laid.rects[&host].height, 40.0,
"block-axis inline padding/border must not increase line advance"
);
assert_eq!(laid.rects[&control_host].height, 40.0);
assert_eq!(laid.rects[&token].height, expected_fragment_height);
assert_eq!(laid.inline_fragments[&token], vec![laid.rects[&token]]);
assert_eq!(
laid.rects[&token].y - laid.rects[&host].y,
(40.0 - raw_font_height) / 2.0 - 5.0 - 2.0
);
assert_eq!(
laid.rects[&control].y - laid.rects[&control_host].y,
laid.rects[&token].y - laid.rects[&host].y,
"ordinary inline block-axis margins must not move its fragment"
);
}
#[test]
fn fallback_word_boxes_shape_and_paint_with_the_loaded_webfont() {
let tree = parse_html(
r#"<style>
html,body,p { margin:0 }
#token {
position:relative;
font-family:Fixture, sans-serif;
font-size:40px;
line-height:50px;
background:red
}
</style>
<p><span id="token">WWWWiiii</span></p>"#,
);
let token = tree.get_element_by_id("token").unwrap();
let text = rendered_children(&tree, token)[0];
let fallback = layout_dom(&tree, (500.0, 150.0));
let loaded = layout_dom_with_web_fonts(
&tree,
(500.0, 150.0),
&HashMap::new(),
&[crate::inline::WebFont {
data: include_bytes!("../assets/liberation-serif.ttf").to_vec(),
family: Some("Fixture".to_string()),
weight: Some((400, 400)),
italic: Some(false),
}],
);
assert!(
loaded.word_ifc_items.get(&text).is_some_and(|items| !items.is_empty()),
"a decorated/relative inline fallback must retain shaped word items"
);
assert_ne!(
fallback.rects[&token].width, loaded.rects[&token].width,
"the loaded face's advances must drive fallback inline geometry"
);
assert_ne!(
fallback.rects[&token].height, loaded.rects[&token].height,
"the loaded face's raw ascent/descent must drive the visual fragment"
);
assert_eq!(
loaded.inline_fragments[&token],
vec![loaded.rects[&token]],
"the loaded-font fragment must be the geometry/paint source"
);
}
#[test]
fn positioned_pseudo_inherits_the_hosts_computed_font_metrics() {
let tree = parse_html(