render: preserve forced line break geometry

This commit is contained in:
SGavrl committed 2026-07-23 21:07:10 +00:00
1 parent f6bf57809b
commit 706e110a24
3 files changed
+75 -15

No files matched your search

+18 -1
View File
@@ -2368,6 +2368,20 @@ fn build(
let mut taffy_style = to_taffy_style(style);
// A forced break that reaches the general box builder sits between block
// or atomic siblings, so it cannot be folded into a shaped inline run.
// Give that sentinel the inherited used line-height instead of the UA
// style's zero placeholder. This is the block/flex analogue of BRFrame's
// empty-line contribution; break tags inside text and mixed block runs
// are handled by the inline collector below.
if _name.local.as_ref() == "br" {
let height = crate::inline::used_line_height(style).max(0.0);
taffy_style.size.height = taffy::style::Dimension::length(height);
let leaf = taffy_tree.new_leaf(taffy_style).ok()?;
id_map.insert(leaf, id);
return Some(leaf);
}
// CSS has separate outer and inner display types, while taffy exposes one
// display value. An inline-block normally needs our wrapping inline
// approximation so it remains atomic in an inline formatting context.
@@ -2763,7 +2777,10 @@ fn build_mixed_block(
out_of_flow.push(cid);
continue;
}
s.display == crate::Display::Inline
let is_forced_break = node
.as_element()
.map_or(false, |element| element.local.as_ref() == "br");
is_forced_break || s.display == crate::Display::Inline
} else {
false
};
+53 -10
View File
@@ -95,6 +95,21 @@ fn normal_line_height(font_size: f32, family: &str) -> f32 {
let scale = font_size / 2048.0;
(ascent * scale).round() + (descent * scale).round() + (line_gap * scale).round()
}
/// Computed used line-height shared by shaped inline runs and forced-break
/// sentinels that cannot join a run.
pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
let font_size = style.font_size.unwrap_or(16.0);
match style.line_height {
Some(crate::LineHeight::Px(px)) => px,
Some(crate::LineHeight::Ratio(ratio)) => font_size * ratio,
None | Some(crate::LineHeight::Normal) => normal_line_height(
font_size,
resolve_font_family(style.font_family.as_deref()),
),
}
}
/// Underline is flagged per glyph through cosmic-text's `metadata` field.
const META_UNDERLINE: usize = 1;
@@ -102,6 +117,10 @@ const META_UNDERLINE: usize = 1;
/// paint it (filled in after layout).
pub struct InlineItem {
buffer: Buffer,
/// Minimum block-size contributed by explicit `<br>` breaks. cosmic-text
/// omits the final empty run for a trailing newline, while CSS still gives
/// a break-only or consecutive-break line the parent's used line-height.
forced_min_height: f32,
/// Content-box top-left in viewport coordinates, set by `finalize`.
origin: (f32, f32),
/// Ancestor `overflow: hidden` clip, set by `finalize`.
@@ -280,7 +299,10 @@ impl TextEngine {
text.pop();
}
}
if spans.iter().all(|(t, _)| t.trim().is_empty()) {
if spans
.iter()
.all(|(text, _)| text.trim().is_empty() && !text.contains('\n'))
{
return None;
}
@@ -288,12 +310,22 @@ impl TextEngine {
// Explicit line-height stays fractional. `normal` is derived from the
// embedded face metrics with the same per-component pixel fitting as
// Chromium's Linux font path.
let family = resolve_font_family(base.font_family.as_deref());
let line_h = match base.line_height {
Some(crate::LineHeight::Px(px)) => px,
Some(crate::LineHeight::Ratio(r)) => base_size * r,
_ => normal_line_height(base_size, family),
};
let line_h = used_line_height(base);
let mut forced_breaks = 0usize;
let mut visible_after_last_break = false;
for (text, _) in &spans {
for ch in text.chars() {
if ch == '\n' {
forced_breaks += 1;
visible_after_last_break = false;
} else if !ch.is_whitespace() {
visible_after_last_break = true;
}
}
}
let forced_lines =
forced_breaks + usize::from(forced_breaks > 0 && visible_after_last_break);
let forced_min_height = forced_lines as f32 * line_h.max(1.0);
// cosmic-text asserts (an uncatchable process abort) if font size OR
// line height is 0. `font-size:0` is a common whitespace-collapse trick
// and drives both to 0, so floor both at 1px here. The glyphs stay
@@ -326,7 +358,13 @@ impl TextEngine {
}
let idx = self.items.len();
self.items.push(InlineItem { buffer, origin: (0.0, 0.0), clip: None, clip_fill });
self.items.push(InlineItem {
buffer,
forced_min_height,
origin: (0.0, 0.0),
clip: None,
clip_fill,
});
Some(idx)
}
@@ -343,7 +381,8 @@ impl TextEngine {
let item = &mut items[idx];
item.buffer.set_size(font_system, width.map(|w| w.max(0.0)), None);
item.buffer.shape_until_scroll(font_system, false);
buffer_size(&item.buffer)
let (width, height) = buffer_size(&item.buffer);
(width, height.max(item.forced_min_height))
}
/// Register a replaced element's intrinsic size as a taffy measure
@@ -636,6 +675,7 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM
return true; // removed from flow; ignore its subtree
}
if elem.local.as_ref() == "br" {
*has_text = true;
return true;
}
// A replaced element or an atomic inline-block has its own box with
@@ -804,7 +844,10 @@ impl TextEngine {
// `try_build`) so this coverage exists; without a clip fill the per-span
// colors pass through unchanged.
let clip_fill = item.clip_fill.clone();
let fill_extent = clip_fill.as_ref().map(|_| buffer_size(&item.buffer));
let fill_extent = clip_fill.as_ref().map(|_| {
let (width, height) = buffer_size(&item.buffer);
(width, height.max(item.forced_min_height))
});
let pixels = pixmap.pixels_mut();
item.buffer.draw(font_system, swash, default, |gx, gy, gw, gh, color| {
let a = color.a() as u32;
+4 -4
View File
@@ -67,10 +67,10 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
// which otherwise paints an empty box the size of the image).
style.display = crate::Display::None;
} else if tag == "br" {
// Forces a line break inside our flex-row-wrap approximation of
// inline flow (see `dom::build`'s "stacks_children_vertically"
// promotion): a full-width, zero-height item leaves no room for
// anything else on its line, so the next inline item wraps.
// Keep forced breaks as full-width sentinels in the general
// flex/block fallback. The builder replaces the zero height with the
// inherited used line-height; pure and mixed inline runs fold the tag
// into the shaped line stream instead.
style.width = crate::Dimension::Percent(1.0);
style.height = crate::Dimension::Px(0.0);
} else if tag == "body" {