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); 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 // CSS has separate outer and inner display types, while taffy exposes one
// display value. An inline-block normally needs our wrapping inline // display value. An inline-block normally needs our wrapping inline
// approximation so it remains atomic in an inline formatting context. // approximation so it remains atomic in an inline formatting context.
@@ -2763,7 +2777,10 @@ fn build_mixed_block(
out_of_flow.push(cid); out_of_flow.push(cid);
continue; 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 { } else {
false 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; let scale = font_size / 2048.0;
(ascent * scale).round() + (descent * scale).round() + (line_gap * scale).round() (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. /// Underline is flagged per glyph through cosmic-text's `metadata` field.
const META_UNDERLINE: usize = 1; const META_UNDERLINE: usize = 1;
@@ -102,6 +117,10 @@ const META_UNDERLINE: usize = 1;
/// paint it (filled in after layout). /// paint it (filled in after layout).
pub struct InlineItem { pub struct InlineItem {
buffer: Buffer, 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`. /// Content-box top-left in viewport coordinates, set by `finalize`.
origin: (f32, f32), origin: (f32, f32),
/// Ancestor `overflow: hidden` clip, set by `finalize`. /// Ancestor `overflow: hidden` clip, set by `finalize`.
@@ -280,7 +299,10 @@ impl TextEngine {
text.pop(); 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; return None;
} }
@@ -288,12 +310,22 @@ impl TextEngine {
// Explicit line-height stays fractional. `normal` is derived from the // Explicit line-height stays fractional. `normal` is derived from the
// embedded face metrics with the same per-component pixel fitting as // embedded face metrics with the same per-component pixel fitting as
// Chromium's Linux font path. // Chromium's Linux font path.
let family = resolve_font_family(base.font_family.as_deref()); let line_h = used_line_height(base);
let line_h = match base.line_height { let mut forced_breaks = 0usize;
Some(crate::LineHeight::Px(px)) => px, let mut visible_after_last_break = false;
Some(crate::LineHeight::Ratio(r)) => base_size * r, for (text, _) in &spans {
_ => normal_line_height(base_size, family), 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 // cosmic-text asserts (an uncatchable process abort) if font size OR
// line height is 0. `font-size:0` is a common whitespace-collapse trick // 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 // 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(); 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) Some(idx)
} }
@@ -343,7 +381,8 @@ impl TextEngine {
let item = &mut items[idx]; let item = &mut items[idx];
item.buffer.set_size(font_system, width.map(|w| w.max(0.0)), None); item.buffer.set_size(font_system, width.map(|w| w.max(0.0)), None);
item.buffer.shape_until_scroll(font_system, false); 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 /// 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 return true; // removed from flow; ignore its subtree
} }
if elem.local.as_ref() == "br" { if elem.local.as_ref() == "br" {
*has_text = true;
return true; return true;
} }
// A replaced element or an atomic inline-block has its own box with // 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 // `try_build`) so this coverage exists; without a clip fill the per-span
// colors pass through unchanged. // colors pass through unchanged.
let clip_fill = item.clip_fill.clone(); 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(); let pixels = pixmap.pixels_mut();
item.buffer.draw(font_system, swash, default, |gx, gy, gw, gh, color| { item.buffer.draw(font_system, swash, default, |gx, gy, gw, gh, color| {
let a = color.a() as u32; 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). // which otherwise paints an empty box the size of the image).
style.display = crate::Display::None; style.display = crate::Display::None;
} else if tag == "br" { } else if tag == "br" {
// Forces a line break inside our flex-row-wrap approximation of // Keep forced breaks as full-width sentinels in the general
// inline flow (see `dom::build`'s "stacks_children_vertically" // flex/block fallback. The builder replaces the zero height with the
// promotion): a full-width, zero-height item leaves no room for // inherited used line-height; pure and mixed inline runs fold the tag
// anything else on its line, so the next inline item wraps. // into the shaped line stream instead.
style.width = crate::Dimension::Percent(1.0); style.width = crate::Dimension::Percent(1.0);
style.height = crate::Dimension::Px(0.0); style.height = crate::Dimension::Px(0.0);
} else if tag == "body" { } else if tag == "body" {