Honor preformatted whitespace in shaped text

This commit is contained in:
SGavrl
2026-07-31 01:23:47 +00:00
parent 139d4e77e1
commit 476191d9c8
3 changed files with 146 additions and 11 deletions

View File

@@ -878,6 +878,7 @@ impl TextEngine {
push_text(
text,
context.transform,
context.white_space,
&attrs,
&mut spans,
&mut collector,
@@ -894,15 +895,32 @@ impl TextEngine {
mut spans: Vec<(String, SpanAttrs)>,
clip_fills: Vec<ClipTextFill>,
) -> Option<usize> {
// Trim a single trailing space so it does not widen the last line.
if let Some((text, _)) = spans.last_mut() {
if text.ends_with(' ') {
text.pop();
let white_space = base.white_space.unwrap_or_default();
// Collapsible trailing whitespace does not widen the last line.
if matches!(
white_space,
crate::WhiteSpace::Normal
| crate::WhiteSpace::NoWrap
| crate::WhiteSpace::PreLine
) {
if let Some((text, _)) = spans.last_mut() {
if text.ends_with(' ') {
text.pop();
}
}
}
if spans
.iter()
.all(|(text, _)| text.trim().is_empty() && !text.contains('\n'))
.all(|(text, _)| {
text.is_empty()
|| (matches!(
white_space,
crate::WhiteSpace::Normal
| crate::WhiteSpace::NoWrap
| crate::WhiteSpace::PreLine
) && text.trim().is_empty()
&& !text.contains('\n'))
})
{
return None;
}
@@ -939,7 +957,17 @@ impl TextEngine {
// so a table/flex column never squeezes narrower than a whole word
// and forces an ugly mid-word break; a single word wider than its
// column overflows instead, matching normal `overflow-wrap: normal`.
buffer.set_wrap(&mut self.font_system, Wrap::Word);
buffer.set_wrap(
&mut self.font_system,
if matches!(
white_space,
crate::WhiteSpace::NoWrap | crate::WhiteSpace::Pre
) {
Wrap::None
} else {
Wrap::Word
},
);
// Always Advanced shaping: Basic mis-maps per-span attribute
// boundaries in the shaping backend (a multi-color run like body text
// with links ends up coloring the wrong glyphs), and shaping is a
@@ -1160,6 +1188,7 @@ struct SpanCtx {
italic: bool,
underline: bool,
transform: TextTransform,
white_space: crate::WhiteSpace,
family: Arc<str>,
clip_fill: Option<usize>,
}
@@ -1222,6 +1251,7 @@ fn base_span_ctx(
italic: base.font_style_italic.unwrap_or(false),
underline: base.underline.unwrap_or(false),
transform: base.text_transform.unwrap_or(TextTransform::None),
white_space: base.white_space.unwrap_or_default(),
family: font.family,
clip_fill,
}
@@ -1280,7 +1310,14 @@ fn collect_node_spans(
family: Arc::clone(&ctx.family),
clip_fill: ctx.clip_fill,
};
push_text(contents, ctx.transform, &attrs, out, c);
push_text(
contents,
ctx.transform,
ctx.white_space,
&attrs,
out,
c,
);
}
_ => {
let Some(elem) = node.as_element() else { return };
@@ -1366,6 +1403,9 @@ fn collect_node_spans(
// 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),
white_space: style
.and_then(|s| s.white_space)
.unwrap_or(ctx.white_space),
family: font.family,
clip_fill,
};
@@ -1382,15 +1422,32 @@ fn collect_node_spans(
}
}
fn push_text(raw: &str, transform: TextTransform, attrs: &SpanAttrs, out: &mut Vec<(String, SpanAttrs)>, c: &mut Collector) {
fn push_text(
raw: &str,
transform: TextTransform,
white_space: crate::WhiteSpace,
attrs: &SpanAttrs,
out: &mut Vec<(String, SpanAttrs)>,
c: &mut Collector,
) {
let mut buf = String::new();
let mut at_word_start = c.last_was_space;
for ch in raw.chars() {
if ch.is_whitespace() {
if !c.last_was_space {
buf.push(' ');
c.last_was_space = true;
match white_space {
crate::WhiteSpace::Pre
| crate::WhiteSpace::PreWrap
| crate::WhiteSpace::BreakSpaces => buf.push(ch),
crate::WhiteSpace::PreLine if ch == '\n' => {
if buf.ends_with(' ') {
buf.pop();
}
buf.push('\n');
}
_ if !c.last_was_space => buf.push(' '),
_ => {}
}
c.last_was_space = true;
at_word_start = true;
} else {
match transform {
@@ -2648,6 +2705,43 @@ mod tests {
);
}
#[test]
fn preformatted_newlines_preserve_authored_line_count() {
let tree = obscura_dom::parse_html(
r#"<style>
html,body { margin:0 }
.box { margin:0; width:200px; font:16px/24px monospace }
#explicit { white-space:pre-wrap }
#normal { white-space:normal }
</style>
<pre id="ua" class="box"><code>alpha
beta
gamma</code></pre>
<div id="explicit" class="box">alpha
beta
gamma</div>
<div id="normal" class="box">alpha
beta
gamma</div>"#,
);
let laid = crate::dom::layout_dom(&tree, (400.0, 300.0));
let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()];
assert_eq!(laid.styles[&tree.get_element_by_id("ua").unwrap()].white_space, Some(crate::WhiteSpace::Pre));
assert_eq!(laid.styles[&tree.get_element_by_id("explicit").unwrap()].white_space, Some(crate::WhiteSpace::PreWrap));
assert!((rect("ua").height - 72.0).abs() < 0.01, "{:?}", rect("ua"));
assert!(
(rect("explicit").height - 72.0).abs() < 0.01,
"{:?}",
rect("explicit")
);
assert!(
(rect("normal").height - 24.0).abs() < 0.01,
"{:?}",
rect("normal")
);
}
#[test]
fn clip_fill_only_for_transparent_clip_text() {
let mut s = LayoutStyle::default();

View File

@@ -615,6 +615,10 @@ pub struct LayoutStyle {
/// shaped text (a fixed ratio made real-site prose noticeably tighter than
/// Chromium).
pub line_height: Option<LineHeight>,
/// `white-space`. Inherited; `None` means inherit the nearest ancestor's
/// value (or the initial `normal`). The inline shaper uses this to retain
/// author/source newlines in code blocks and to select wrapping behavior.
pub white_space: Option<WhiteSpace>,
/// Deferred functional line-height (`calc()`, `min()`, `clamp()`) resolved
/// after the element font and live viewport are known.
pub line_height_expression: Option<String>,
@@ -786,6 +790,20 @@ pub enum LineHeight {
Relative(Dimension),
}
/// The legacy `white-space` shorthand values needed by inline collection and
/// line breaking. `BreakSpaces` currently shares pre-wrap's wrapping model;
/// its finer trailing-space opportunity rules can be added independently.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WhiteSpace {
#[default]
Normal,
NoWrap,
Pre,
PreWrap,
PreLine,
BreakSpaces,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextTransform {
None,

View File

@@ -74,6 +74,9 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
// into the shaped line stream instead.
style.width = crate::Dimension::Percent(1.0);
style.height = crate::Dimension::Px(0.0);
} else if tag == "pre" {
// HTML's UA sheet preserves source whitespace in preformatted blocks.
style.white_space = Some(crate::WhiteSpace::Pre);
} else if tag == "body" {
style.margin = Edges { top: 8.0, right: 8.0, bottom: 8.0, left: 8.0 };
} else if tag == "h1" {
@@ -1174,6 +1177,21 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
v.parse::<f32>().ok().map(crate::LineHeight::Ratio)
};
}
"white-space" => {
style.white_space = match value.trim().to_ascii_lowercase().as_str() {
"normal" | "initial" | "revert" | "revert-layer" => {
Some(crate::WhiteSpace::Normal)
}
"nowrap" => Some(crate::WhiteSpace::NoWrap),
"pre" => Some(crate::WhiteSpace::Pre),
"pre-wrap" => Some(crate::WhiteSpace::PreWrap),
"pre-line" => Some(crate::WhiteSpace::PreLine),
"break-spaces" => Some(crate::WhiteSpace::BreakSpaces),
// `white-space` inherits, so unset behaves as inherit.
"inherit" | "unset" => None,
_ => style.white_space,
};
}
"font-style" => {
let v = value.trim().to_ascii_lowercase();
style.font_style_italic = Some(v.starts_with("italic") || v.starts_with("oblique"));
@@ -1414,6 +1432,7 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
| "text-decoration"
| "text-decoration-line"
| "line-height"
| "white-space"
| "align-items"
| "justify-items"
| "place-items"
@@ -1533,6 +1552,10 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
value.eq_ignore_ascii_case("normal")
|| parse_font_variation_settings(value).is_some()
}
"white-space" => matches!(
value.to_ascii_lowercase().as_str(),
"normal" | "nowrap" | "pre" | "pre-wrap" | "pre-line" | "break-spaces"
),
"float" => matches!(
value.to_ascii_lowercase().as_str(),
"none" | "left" | "right"