render: resolve contextual positioned lengths

This commit is contained in:
SGavrl committed 2026-07-26 14:09:49 +00:00
1 parent d9f9202737
commit ca551a996b
4 files changed
+243 -38

No files matched your search

+57
View File
@@ -676,11 +676,68 @@ pub fn layout_dom_with_images(
style.height, style.height,
crate::Dimension::Px(_) | crate::Dimension::Percent(_) crate::Dimension::Px(_) | crate::Dimension::Percent(_)
); );
for index in 0..4 {
let Some(expression) =
style.inset_expressions[index].as_deref()
else {
continue;
};
let percent_base = if matches!(index, 1 | 3) {
cb_w
} else {
viewport.1
};
style.inset[index] =
crate::style::resolve_contextual_length(
expression,
em_px,
root_fs,
vw,
vh,
percent_base,
)
.map(crate::Dimension::Px);
}
for i in style.inset.iter_mut() { for i in style.inset.iter_mut() {
if let Some(d) = i { if let Some(d) = i {
*i = Some(d.resolve(em_px, root_fs, vw, vh)); *i = Some(d.resolve(em_px, root_fs, vw, vh));
} }
} }
// A fixed box is positioned against the initial containing
// block, whose dimensions are the viewport, not the full
// scrollable root element. Taffy attaches out-of-flow boxes to
// a layout node, and the root node can grow to the document's
// full content height. Make the CSS 2.1 stretch equation
// explicit for the ubiquitous `position:fixed; inset:0` case
// so overlays/canvases remain exactly viewport-sized.
if style.position_fixed {
if style.width == crate::Dimension::Auto {
if let (
Some(crate::Dimension::Px(right)),
Some(crate::Dimension::Px(left)),
) = (style.inset[1], style.inset[3])
{
style.width = crate::Dimension::Px(
(viewport.0 - left - right).max(0.0),
);
}
}
if style.height == crate::Dimension::Auto {
if let (
Some(crate::Dimension::Px(top)),
Some(crate::Dimension::Px(bottom)),
) = (style.inset[0], style.inset[2])
{
style.height = crate::Dimension::Px(
(viewport.1 - top - bottom).max(0.0),
);
}
}
child_cb_height_definite = matches!(
style.height,
crate::Dimension::Px(_) | crate::Dimension::Percent(_)
);
}
match &style.font_weight { Some(w) => inh.font_weight = Some(w.clone()), None => style.font_weight = inh.font_weight.clone() } match &style.font_weight { Some(w) => inh.font_weight = Some(w.clone()), None => style.font_weight = inh.font_weight.clone() }
match &style.font_family { Some(f) => inh.font_family = Some(f.clone()), None => style.font_family = inh.font_family.clone() } match &style.font_family { Some(f) => inh.font_family = Some(f.clone()), None => style.font_family = inh.font_family.clone() }
let is_table = tree let is_table = tree
+2
View File
@@ -375,6 +375,8 @@ pub struct LayoutStyle {
/// layout mode, but fixed boxes use the initial containing block. /// layout mode, but fixed boxes use the initial containing block.
pub position_fixed: bool, pub position_fixed: bool,
pub inset: [Option<Dimension>; 4], // top, right, bottom, left pub inset: [Option<Dimension>; 4], // top, right, bottom, left
/// Deferred functional inset expressions in top/right/bottom/left order.
pub inset_expressions: [Option<String>; 4],
/// `overflow`/-x/-y other than `visible`: clips this element's descendants /// `overflow`/-x/-y other than `visible`: clips this element's descendants
/// to its border box during paint. This is what makes the ubiquitous /// to its border box during paint. This is what makes the ubiquitous
+114 -38
View File
@@ -625,13 +625,13 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
_ => {} _ => {}
} }
}, },
"top" => style.inset[0] = inset_dim(value), "top" => set_inset_side(style, 0, value),
"right" => style.inset[1] = inset_dim(value), "right" => set_inset_side(style, 1, value),
"bottom" => style.inset[2] = inset_dim(value), "bottom" => set_inset_side(style, 2, value),
"left" => style.inset[3] = inset_dim(value), "left" => set_inset_side(style, 3, value),
"inset" => { "inset" => {
// 1-4 values, CSS shorthand order: all / v h / t h b / t r b l. // 1-4 values, CSS shorthand order: all / v h / t h b / t r b l.
let parts: Vec<crate::Dimension> = value.split_whitespace().map(dimension_value).collect(); let parts = split_ws_paren(value);
let (t, r, b, l) = match parts.as_slice() { let (t, r, b, l) = match parts.as_slice() {
[a] => (*a, *a, *a, *a), [a] => (*a, *a, *a, *a),
[v, h] => (*v, *h, *v, *h), [v, h] => (*v, *h, *v, *h),
@@ -639,7 +639,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
[t, r, b, l, ..] => (*t, *r, *b, *l), [t, r, b, l, ..] => (*t, *r, *b, *l),
[] => return, [] => return,
}; };
style.inset = [Some(t), Some(r), Some(b), Some(l)]; set_inset_side(style, 0, t);
set_inset_side(style, 1, r);
set_inset_side(style, 2, b);
set_inset_side(style, 3, l);
} }
"overflow" | "overflow-x" | "overflow-y" => { "overflow" | "overflow-x" | "overflow-y" => {
style.overflow_hidden = value != "visible"; style.overflow_hidden = value != "visible";
@@ -1876,6 +1879,12 @@ struct LengthContext {
fn resolve_contextual(value: &str, context: &LengthContext) -> Option<f32> { fn resolve_contextual(value: &str, context: &LengthContext) -> Option<f32> {
let value = value.trim(); let value = value.trim();
if let Some(rest) = value.strip_prefix('(') {
let end = find_matching_paren(rest)?;
if end + 2 == value.len() {
return eval_contextual_calc(&rest[..end], context);
}
}
if let Some(rest) = value.strip_prefix("var(") { if let Some(rest) = value.strip_prefix("var(") {
let end = find_matching_paren(rest)?; let end = find_matching_paren(rest)?;
let inner = &rest[..end]; let inner = &rest[..end];
@@ -1895,7 +1904,7 @@ fn resolve_contextual(value: &str, context: &LengthContext) -> Option<f32> {
let args = split_top_level(&rest[..end], ','); let args = split_top_level(&rest[..end], ',');
let mut values = args let mut values = args
.iter() .iter()
.filter_map(|arg| resolve_contextual(arg, context)); .filter_map(|arg| eval_contextual_calc(arg, context));
let mut best = values.next()?; let mut best = values.next()?;
for candidate in values { for candidate in values {
if (is_max && candidate > best) || (!is_max && candidate < best) { if (is_max && candidate > best) || (!is_max && candidate < best) {
@@ -1908,9 +1917,9 @@ fn resolve_contextual(value: &str, context: &LengthContext) -> Option<f32> {
let end = find_matching_paren(rest)?; let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ','); let args = split_top_level(&rest[..end], ',');
if args.len() == 3 { if args.len() == 3 {
let low = resolve_contextual(args[0], context)?; let low = eval_contextual_calc(args[0], context)?;
let preferred = resolve_contextual(args[1], context)?; let preferred = eval_contextual_calc(args[1], context)?;
let high = resolve_contextual(args[2], context)?; let high = eval_contextual_calc(args[2], context)?;
return Some(preferred.min(high).max(low)); return Some(preferred.min(high).max(low));
} }
} }
@@ -1963,6 +1972,22 @@ fn contextual_atom(value: &str, context: &LengthContext) -> Option<f32> {
if let Some(value) = lower.strip_suffix("vmax").and_then(parse) { if let Some(value) = lower.strip_suffix("vmax").and_then(parse) {
return Some(value * context.vw.max(context.vh)); return Some(value * context.vw.max(context.vh));
} }
if let Some(value) = lower
.strip_suffix("dvw")
.or_else(|| lower.strip_suffix("svw"))
.or_else(|| lower.strip_suffix("lvw"))
.and_then(parse)
{
return Some(value * context.vw);
}
if let Some(value) = lower
.strip_suffix("dvh")
.or_else(|| lower.strip_suffix("svh"))
.or_else(|| lower.strip_suffix("lvh"))
.and_then(parse)
{
return Some(value * context.vh);
}
if let Some(value) = lower.strip_suffix("vw").and_then(parse) { if let Some(value) = lower.strip_suffix("vw").and_then(parse) {
return Some(value * context.vw); return Some(value * context.vw);
} }
@@ -2019,11 +2044,11 @@ fn eval_contextual_calc(expr: &str, context: &LengthContext) -> Option<f32> {
} }
fn eval_contextual_product(term: &str, context: &LengthContext) -> Option<f32> { fn eval_contextual_product(term: &str, context: &LengthContext) -> Option<f32> {
let mut result = None; let mut result: Option<f32> = None;
let mut operator = '*'; let mut operator = '*';
let mut depth = 0i32; let mut depth = 0i32;
let mut current = String::new(); let mut current = String::new();
let mut factors = Vec::new(); let mut factors: Vec<(char, String)> = Vec::new();
for character in term.chars() { for character in term.chars() {
match character { match character {
'(' => { '(' => {
@@ -2034,26 +2059,25 @@ fn eval_contextual_product(term: &str, context: &LengthContext) -> Option<f32> {
depth -= 1; depth -= 1;
current.push(character); current.push(character);
} }
' ' if depth == 0 => { '*' | '/' if depth == 0 => {
if !current.is_empty() { if current.trim().is_empty() {
factors.push(std::mem::take(&mut current)); return None;
} }
factors.push((operator, std::mem::take(&mut current)));
operator = character;
} }
_ => current.push(character), _ => current.push(character),
} }
} }
if !current.is_empty() { if current.trim().is_empty() {
factors.push(current); return None;
} }
for factor in &factors { factors.push((operator, current));
if factor == "*" || factor == "/" { for (operator, factor) in &factors {
operator = factor.chars().next()?;
continue;
}
let value = resolve_contextual(factor, context)?; let value = resolve_contextual(factor, context)?;
result = Some(match result { result = Some(match result {
None => value, None => value,
Some(previous) if operator == '/' => previous / value, Some(previous) if *operator == '/' => previous / value,
Some(previous) => previous * value, Some(previous) => previous * value,
}); });
} }
@@ -2069,6 +2093,12 @@ fn eval_contextual_product(term: &str, context: &LengthContext) -> Option<f32> {
/// this function rather than assuming a flat expression. /// this function rather than assuming a flat expression.
fn resolve_length(value: &str) -> Option<f32> { fn resolve_length(value: &str) -> Option<f32> {
let v = value.trim(); let v = value.trim();
if let Some(rest) = v.strip_prefix('(') {
let end = find_matching_paren(rest)?;
if end + 2 == v.len() {
return eval_calc(&rest[..end]);
}
}
if let Some(rest) = v.strip_prefix("var(") { if let Some(rest) = v.strip_prefix("var(") {
let end = find_matching_paren(rest)?; let end = find_matching_paren(rest)?;
let inner = &rest[..end]; let inner = &rest[..end];
@@ -2083,7 +2113,7 @@ fn resolve_length(value: &str) -> Option<f32> {
let is_max = v.starts_with("max("); let is_max = v.starts_with("max(");
let end = find_matching_paren(rest)?; let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ','); let args = split_top_level(&rest[..end], ',');
let mut values = args.iter().filter_map(|a| resolve_length(a)); let mut values = args.iter().filter_map(|a| eval_calc(a));
let mut best = values.next()?; let mut best = values.next()?;
for val in values { for val in values {
if (is_max && val > best) || (!is_max && val < best) { if (is_max && val > best) || (!is_max && val < best) {
@@ -2099,9 +2129,9 @@ fn resolve_length(value: &str) -> Option<f32> {
let end = find_matching_paren(rest)?; let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ','); let args = split_top_level(&rest[..end], ',');
if args.len() == 3 { if args.len() == 3 {
let lo = resolve_length(args[0].trim())?; let lo = eval_calc(args[0].trim())?;
let mid = resolve_length(args[1].trim())?; let mid = eval_calc(args[1].trim())?;
let hi = resolve_length(args[2].trim())?; let hi = eval_calc(args[2].trim())?;
return Some(mid.min(hi).max(lo)); return Some(mid.min(hi).max(lo));
} }
} }
@@ -2206,28 +2236,31 @@ fn eval_product(term: &str) -> Option<f32> {
let mut op = '*'; let mut op = '*';
let mut depth = 0i32; let mut depth = 0i32;
let mut cur = String::new(); let mut cur = String::new();
let mut factors = Vec::new(); let mut factors: Vec<(char, String)> = Vec::new();
for c in term.chars() { for c in term.chars() {
match c { match c {
'(' => { depth += 1; cur.push(c); } '(' => { depth += 1; cur.push(c); }
')' => { depth -= 1; cur.push(c); } ')' => { depth -= 1; cur.push(c); }
' ' if depth == 0 => { '*' | '/' if depth == 0 => {
if !cur.is_empty() { factors.push(std::mem::take(&mut cur)); } if cur.trim().is_empty() {
return None;
}
factors.push((op, std::mem::take(&mut cur)));
op = c;
} }
_ => cur.push(c), _ => cur.push(c),
} }
} }
if !cur.is_empty() { factors.push(cur); } if cur.trim().is_empty() {
return None;
}
factors.push((op, cur));
for tok in &factors { for (op, tok) in &factors {
if tok == "*" || tok == "/" {
op = tok.chars().next()?;
continue;
}
let v = resolve_length(tok)?; let v = resolve_length(tok)?;
result = Some(match result { result = Some(match result {
None => v, None => v,
Some(r) if op == '/' => r / v, Some(r) if *op == '/' => r / v,
Some(r) => r * v, Some(r) => r * v,
}); });
} }
@@ -2258,6 +2291,17 @@ fn inset_dim(value: &str) -> Option<crate::Dimension> {
} }
} }
fn set_inset_side(style: &mut LayoutStyle, index: usize, value: &str) {
let value = value.trim();
if let Some(expression) = deferred_length_expression(value) {
style.inset[index] = None;
style.inset_expressions[index] = Some(expression);
} else {
style.inset[index] = inset_dim(value);
style.inset_expressions[index] = None;
}
}
/// Absolute keyword font-sizes (the `medium`-anchored scale), for the handful /// Absolute keyword font-sizes (the `medium`-anchored scale), for the handful
/// of pages that still use them. /// of pages that still use them.
fn font_size_keyword(v: &str) -> Option<f32> { fn font_size_keyword(v: &str) -> Option<f32> {
@@ -2383,7 +2427,10 @@ fn is_font_size_token(value: &str) -> bool {
{ {
return true; return true;
} }
["px", "pt", "em", "ex", "rem", "vw", "vh", "vmin", "vmax", "%"] [
"px", "pt", "em", "ex", "rem", "vw", "vh", "dvw", "dvh", "svw",
"svh", "lvw", "lvh", "vmin", "vmax", "%",
]
.iter() .iter()
.any(|unit| lower.strip_suffix(unit).and_then(|number| number.parse::<f32>().ok()).is_some()) .any(|unit| lower.strip_suffix(unit).and_then(|number| number.parse::<f32>().ok()).is_some())
} }
@@ -2410,6 +2457,22 @@ fn dimension_value(tok: &str) -> crate::Dimension {
if let Some(v) = lower.strip_suffix("ex").and_then(parse) { return Dimension::Ex(v); } if let Some(v) = lower.strip_suffix("ex").and_then(parse) { return Dimension::Ex(v); }
if let Some(v) = lower.strip_suffix("vmin").and_then(parse) { return Dimension::Vmin(v); } if let Some(v) = lower.strip_suffix("vmin").and_then(parse) { return Dimension::Vmin(v); }
if let Some(v) = lower.strip_suffix("vmax").and_then(parse) { return Dimension::Vmax(v); } if let Some(v) = lower.strip_suffix("vmax").and_then(parse) { return Dimension::Vmax(v); }
if let Some(v) = lower
.strip_suffix("dvw")
.or_else(|| lower.strip_suffix("svw"))
.or_else(|| lower.strip_suffix("lvw"))
.and_then(parse)
{
return Dimension::Vw(v);
}
if let Some(v) = lower
.strip_suffix("dvh")
.or_else(|| lower.strip_suffix("svh"))
.or_else(|| lower.strip_suffix("lvh"))
.and_then(parse)
{
return Dimension::Vh(v);
}
if let Some(v) = lower.strip_suffix("vw").and_then(parse) { return Dimension::Vw(v); } if let Some(v) = lower.strip_suffix("vw").and_then(parse) { return Dimension::Vw(v); }
if let Some(v) = lower.strip_suffix("vh").and_then(parse) { return Dimension::Vh(v); } if let Some(v) = lower.strip_suffix("vh").and_then(parse) { return Dimension::Vh(v); }
if let Some(v) = lower.strip_suffix("px").and_then(parse) { return Dimension::Px(v); } if let Some(v) = lower.strip_suffix("px").and_then(parse) { return Dimension::Px(v); }
@@ -3407,6 +3470,19 @@ mod tests {
), ),
Some(250.0) Some(250.0)
); );
let grouped = resolve_contextual_length(
"calc(clamp(128px,92px + 7vw,188px) + (100vw - 48px)*43/440)",
context.0,
context.1,
context.2,
context.3,
context.4,
)
.unwrap();
assert!(
(grouped - 238.263_64).abs() < 0.001,
"grouped calc should preserve the nested subtraction: {grouped}"
);
} }
#[test] #[test]
@@ -102,6 +102,45 @@ fn relative_units_resolve_against_viewport_and_font_size() {
assert!(hit, "expected a 500x160 box from 50vw/10em, rects: {:?}", layout.rects.values().map(|r| (r.width, r.height)).collect::<Vec<_>>()); assert!(hit, "expected a 500x160 box from 50vw/10em, rects: {:?}", layout.rects.values().map(|r| (r.width, r.height)).collect::<Vec<_>>());
} }
#[test]
fn dynamic_viewport_units_use_the_live_viewport() {
let tree = parse_html(
r#"<body style="margin:0"><div style="width:25dvw;height:15dvh"></div></body>"#,
);
let layout = layout_dom(&tree, (1000.0, 800.0));
assert!(
layout
.rects
.values()
.any(|rect| (rect.width - 250.0).abs() < 0.01
&& (rect.height - 120.0).abs() < 0.01),
"dvw/dvh should resolve against the capture viewport"
);
}
#[test]
fn fixed_inset_box_uses_viewport_not_document_height() {
let tree = parse_html(
r#"
<body style="margin:0">
<div style="height:5000px"></div>
<div id="overlay" style="position:fixed;inset:0">
<canvas id="surface" style="width:100%;height:100%"></canvas>
</div>
</body>
"#,
);
let layout = layout_dom(&tree, (900.0, 1000.0));
for id in ["overlay", "surface"] {
let rect = layout.rects[&tree.get_element_by_id(id).unwrap()];
assert!(
(rect.width - 900.0).abs() < 0.01
&& (rect.height - 1000.0).abs() < 0.01,
"{id} should fill the viewport rather than the 5000px document: {rect:?}"
);
}
}
#[test] #[test]
fn percentage_padding_top_reserves_aspect_ratio_box() { fn percentage_padding_top_reserves_aspect_ratio_box() {
// The responsive aspect-ratio trick: an empty box with padding-top:56.25% // The responsive aspect-ratio trick: an empty box with padding-top:56.25%
@@ -999,3 +1038,34 @@ fn length_line_height_computes_before_inheritance() {
Some(obscura_render::LineHeight::Ratio(2.0)) Some(obscura_render::LineHeight::Ratio(2.0))
); );
} }
#[test]
fn contextual_right_inset_uses_root_font_tokens() {
let tree = parse_html(
r#"
<style>
:root { --spacing: .25rem }
body { margin: 0 }
#header { position: relative; width: 900px; height: 80px }
#search {
position: absolute;
right: calc(var(--spacing)*14);
width: 100px;
height: 36px;
}
</style>
<div id="header"><button id="search">Search</button></div>
"#,
);
let layout = layout_dom(&tree, (900.0, 1000.0));
let search = tree.get_element_by_id("search").unwrap();
assert_eq!(
layout.styles[&search].inset[1],
Some(obscura_render::Dimension::Px(56.0))
);
let rect = layout.rects[&search];
assert!(
(rect.x - 744.0).abs() < 0.01,
"right inset should place 100px box at 900 - 56 - 100: {rect:?}"
);
}