From d9f9202737ebad83e8dd6621c5b341734115eabd Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:47:09 +0000 Subject: [PATCH] render: resolve modern responsive geometry --- crates/obscura-render/src/css.rs | 279 ++++++++++++++--- crates/obscura-render/src/dom.rs | 68 +++- crates/obscura-render/src/inline.rs | 7 + crates/obscura-render/src/lib.rs | 25 ++ crates/obscura-render/src/paint.rs | 264 ++++++++++++++-- crates/obscura-render/src/style.rs | 341 ++++++++++++++++++++- crates/obscura-render/tests/layout_test.rs | 92 ++++++ 7 files changed, 988 insertions(+), 88 deletions(-) diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 6ba1dc0..76e8185 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -505,6 +505,19 @@ pub(crate) fn media_query_applies(query: &str) -> bool { pub(crate) fn media_query_applies_for_viewport( query: &str, viewport: (f32, f32), +) -> bool { + // A media-query list is an OR, not an AND. Evaluate each top-level comma + // arm independently (commas inside functions such as rgb() / calc() are + // not list separators). This also keeps an inapplicable `print` arm from + // suppressing a later screen/feature arm. + split_media_query_list(query) + .into_iter() + .any(|query| single_media_query_applies_for_viewport(query, viewport)) +} + +fn single_media_query_applies_for_viewport( + query: &str, + viewport: (f32, f32), ) -> bool { let viewport_w = viewport.0; let viewport_h = viewport.1; @@ -540,63 +553,103 @@ pub(crate) fn media_query_applies_for_viewport( // Width constraints, both `min-width:`/`max-width:` and the modern range // forms `width>=Npx` / `(Npx<=width)`. - if let Some(px) = extract_px(&compact, "max-width:") { + if let Some(px) = extract_length(&compact, "max-width:", viewport, LengthAxis::Width) { if viewport_w > px { return false; } } - if let Some(px) = extract_px(&compact, "min-width:") { + if let Some(px) = extract_length(&compact, "min-width:", viewport, LengthAxis::Width) { if viewport_w < px { return false; } } - if let Some(px) = extract_px(&compact, "width<=") { + if let Some(px) = extract_length(&compact, "width<=", viewport, LengthAxis::Width) { if viewport_w > px { return false; } } - if let Some(px) = extract_px(&compact, "width>=") { + if let Some(px) = extract_length(&compact, "width>=", viewport, LengthAxis::Width) { if viewport_w < px { return false; } } - if let Some(px) = extract_px(&compact, "width>") { + if let Some(px) = extract_length(&compact, "width>", viewport, LengthAxis::Width) { if viewport_w <= px { return false; } } - if let Some(px) = extract_px(&compact, "width<") { + if let Some(px) = extract_length(&compact, "width<", viewport, LengthAxis::Width) { + if viewport_w >= px { + return false; + } + } + if let Some(px) = extract_length_before(&compact, "<=width", viewport, LengthAxis::Width) { + if viewport_w < px { + return false; + } + } + if let Some(px) = extract_length_before(&compact, "=width", viewport, LengthAxis::Width) { + if viewport_w > px { + return false; + } + } + if let Some(px) = extract_length_before(&compact, ">width", viewport, LengthAxis::Width) { if viewport_w >= px { return false; } } - if let Some(px) = extract_px(&compact, "max-height:") { + if let Some(px) = extract_length(&compact, "max-height:", viewport, LengthAxis::Height) { if viewport_h > px { return false; } } - if let Some(px) = extract_px(&compact, "min-height:") { + if let Some(px) = extract_length(&compact, "min-height:", viewport, LengthAxis::Height) { if viewport_h < px { return false; } } - if let Some(px) = extract_px(&compact, "height<=") { + if let Some(px) = extract_length(&compact, "height<=", viewport, LengthAxis::Height) { if viewport_h > px { return false; } } - if let Some(px) = extract_px(&compact, "height>=") { + if let Some(px) = extract_length(&compact, "height>=", viewport, LengthAxis::Height) { if viewport_h < px { return false; } } - if let Some(px) = extract_px(&compact, "height>") { + if let Some(px) = extract_length(&compact, "height>", viewport, LengthAxis::Height) { if viewport_h <= px { return false; } } - if let Some(px) = extract_px(&compact, "height<") { + if let Some(px) = extract_length(&compact, "height<", viewport, LengthAxis::Height) { + if viewport_h >= px { + return false; + } + } + if let Some(px) = extract_length_before(&compact, "<=height", viewport, LengthAxis::Height) { + if viewport_h < px { + return false; + } + } + if let Some(px) = extract_length_before(&compact, "=height", viewport, LengthAxis::Height) { + if viewport_h > px { + return false; + } + } + if let Some(px) = extract_length_before(&compact, ">height", viewport, LengthAxis::Height) { if viewport_h >= px { return false; } @@ -818,47 +871,143 @@ fn split_selector_list(sel: &str) -> Vec { out } -/// Read the number immediately following `prop` in `s`. Callers pass a -/// whitespace-stripped `s`, so the first non-digit character always ends the -/// number. Handles a bare px value (`750px`) and the extremely common -/// responsive-breakpoint idiom `calc(750px - 1px)` (real stylesheets use this -/// constantly to express "one pixel narrower than the next breakpoint"): -/// evaluated as a simple left-to-right sum of every px term, since css media -/// feature calc() expressions in practice are always plain +/- of px values, -/// never nested, multiplied, or mixed-unit. -fn extract_px(s: &str, prop: &str) -> Option { +#[derive(Clone, Copy)] +enum LengthAxis { + Width, + Height, +} + +/// Read a CSS length immediately following `prop`. Media-query `em` and `rem` +/// units resolve against the initial font size (16 CSS px), not an element's +/// computed font. Modern utility frameworks deliberately use those units for +/// breakpoints, so treating only `px` as typed made every `min-width:64rem` +/// desktop rule unconditional. +fn extract_length( + s: &str, + prop: &str, + viewport: (f32, f32), + axis: LengthAxis, +) -> Option { let start = s.find(prop)? + prop.len(); let rest = &s[start..]; if let Some(inner) = rest.strip_prefix("calc(") { let end = inner.find(')')?; - return Some(eval_px_sum(&inner[..end])); + return eval_length_sum(&inner[..end], viewport, axis); } - let num: String = rest.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); - num.parse::().ok() + parse_length_prefix(rest, viewport, axis) } -/// Sum a sequence of signed px terms like `1120px-1px` (whitespace already -/// stripped by the caller) left to right. -fn eval_px_sum(expr: &str) -> f32 { +/// Read the length immediately before a range marker (`64rem<=width`). +fn extract_length_before( + s: &str, + marker: &str, + viewport: (f32, f32), + axis: LengthAxis, +) -> Option { + let end = s.find(marker)?; + let prefix = &s[..end]; + let start = prefix + .rfind(|c: char| matches!(c, '(' | ')' | ':' | ',')) + .map_or(0, |idx| idx + 1); + let value = &prefix[start..]; + if let Some(inner) = value.strip_prefix("calc(") { + return eval_length_sum(inner, viewport, axis); + } + parse_length_prefix(value, viewport, axis) +} + +fn parse_length_prefix( + input: &str, + viewport: (f32, f32), + axis: LengthAxis, +) -> Option { + let numeric_len = input + .char_indices() + .take_while(|(_, c)| c.is_ascii_digit() || matches!(c, '.' | '+' | '-')) + .last() + .map_or(0, |(idx, c)| idx + c.len_utf8()); + if numeric_len == 0 { + return None; + } + let value = input[..numeric_len].parse::().ok()?; + let unit: String = input[numeric_len..] + .chars() + .take_while(|c| c.is_ascii_alphabetic() || *c == '%') + .collect(); + let px = match unit.as_str() { + "" | "px" => value, + "em" | "rem" => value * 16.0, + "vw" => value * viewport.0 / 100.0, + "vh" => value * viewport.1 / 100.0, + "vmin" => value * viewport.0.min(viewport.1) / 100.0, + "vmax" => value * viewport.0.max(viewport.1) / 100.0, + "in" => value * 96.0, + "cm" => value * 96.0 / 2.54, + "mm" => value * 96.0 / 25.4, + "q" => value * 96.0 / 101.6, + "pt" => value * 96.0 / 72.0, + "pc" => value * 16.0, + "%" => match axis { + LengthAxis::Width => value * viewport.0 / 100.0, + LengthAxis::Height => value * viewport.1 / 100.0, + }, + _ => return None, + }; + px.is_finite().then_some(px) +} + +/// Sum the common media-query `calc()` form (`64rem - 1px`) left to right. +fn eval_length_sum( + expr: &str, + viewport: (f32, f32), + axis: LengthAxis, +) -> Option { let mut total = 0.0; let mut sign = 1.0; - let mut num = String::new(); - let flush = |num: &mut String, sign: f32, total: &mut f32| { - if let Ok(v) = num.parse::() { - *total += sign * v; - } - num.clear(); + let mut term = String::new(); + let flush = |term: &mut String, sign: f32, total: &mut f32| -> Option<()> { + let value = parse_length_prefix(term, viewport, axis)?; + *total += sign * value; + term.clear(); + Some(()) }; - for c in expr.chars() { + for (idx, c) in expr.char_indices() { match c { - '+' => { flush(&mut num, sign, &mut total); sign = 1.0; } - '-' => { flush(&mut num, sign, &mut total); sign = -1.0; } - c if c.is_ascii_digit() || c == '.' => num.push(c), - _ => {} // unit suffix (px) or anything else: not part of the number + '+' if idx > 0 => { + flush(&mut term, sign, &mut total)?; + sign = 1.0; + } + '-' if idx > 0 => { + flush(&mut term, sign, &mut total)?; + sign = -1.0; + } + _ => term.push(c), } } - flush(&mut num, sign, &mut total); - total + flush(&mut term, sign, &mut total)?; + Some(total) +} + +fn split_media_query_list(query: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0i32; + let mut quote: Option = None; + let mut start = 0usize; + for (idx, c) in query.char_indices() { + match c { + _ if quote == Some(c) => quote = None, + '\'' | '"' if quote.is_none() => quote = Some(c), + '(' if quote.is_none() => depth += 1, + ')' if quote.is_none() => depth = (depth - 1).max(0), + ',' if quote.is_none() && depth == 0 => { + parts.push(query[start..idx].trim()); + start = idx + 1; + } + _ => {} + } + } + parts.push(query[start..].trim()); + parts } #[cfg(test)] @@ -910,4 +1059,54 @@ mod tests { .any(|(_, declarations)| declarations.contains("width:100%")) ); } + + #[test] + fn media_breakpoints_support_font_relative_lengths_and_ranges() { + assert!(!media_query_applies_for_viewport( + "@media (min-width: 64rem)", + (900.0, 1000.0) + )); + assert!(media_query_applies_for_viewport( + "@media (min-width: 64rem)", + (1024.0, 768.0) + )); + assert!(media_query_applies_for_viewport( + "@media (56.25rem <= width)", + (900.0, 1000.0) + )); + assert!(!media_query_applies_for_viewport( + "@media (width > calc(60em - 1px))", + (900.0, 1000.0) + )); + } + + #[test] + fn media_query_lists_are_or_conditions() { + assert!(media_query_applies_for_viewport( + "@media print, (min-width: 64rem)", + (1280.0, 720.0) + )); + assert!(!media_query_applies_for_viewport( + "@media print, (min-width: 64rem)", + (900.0, 1000.0) + )); + } + + #[test] + fn rem_breakpoint_does_not_reveal_desktop_menu_on_narrow_viewport() { + let css = r#" + header .menu-toolkit { display: none } + @media (min-width: 64rem) { + header .menu-toolkit { display: flex } + } + "#; + let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0)); + assert!(narrow.iter().any(|(selector, declarations)| { + selector == "header .menu-toolkit" && declarations.contains("display: none") + })); + assert!(!narrow.iter().any(|(_, declarations)| declarations.contains("display: flex"))); + + let wide = parse_stylesheet_for_viewport(css, (1024.0, 768.0)); + assert!(wide.iter().any(|(_, declarations)| declarations.contains("display: flex"))); + } } diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 457944d..d320900 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -527,9 +527,24 @@ pub fn layout_dom_with_images( let vh = viewport.1 / 100.0; let root_fs = { let s = styles.get(&root_id); - match (s.and_then(|s| s.font_size), s.and_then(|s| s.font_size_raw)) { - (Some(px), _) => px, - (None, Some(d)) => match d.resolve(16.0, 16.0, vw, vh) { + match ( + s.and_then(|s| s.font_size), + s.and_then(|s| s.font_size_raw), + s.and_then(|s| s.font_size_expression.as_deref()), + ) { + (Some(px), _, _) => px, + (None, _, Some(expression)) => { + crate::style::resolve_contextual_length( + expression, + 16.0, + 16.0, + vw, + vh, + 16.0, + ) + .unwrap_or(16.0) + } + (None, Some(d), _) => match d.resolve(16.0, 16.0, vw, vh) { crate::Dimension::Px(p) => p, crate::Dimension::Percent(p) => 16.0 * p, _ => 16.0, @@ -554,7 +569,16 @@ pub fn layout_dom_with_images( // Resolve a relative font-size against the PARENT (em/%) or // ROOT (rem) font-size before inheriting it downward. let parent_fs = inh.font_size.unwrap_or(16.0); - if let Some(raw) = style.font_size_raw { + if let Some(expression) = style.font_size_expression.as_deref() { + style.font_size = crate::style::resolve_contextual_length( + expression, + parent_fs, + root_fs, + vw, + vh, + parent_fs, + ); + } else if let Some(raw) = style.font_size_raw { let resolved = match raw { crate::Dimension::Percent(p) => parent_fs * p, crate::Dimension::Em(v) => parent_fs * v, @@ -569,6 +593,42 @@ pub fn layout_dom_with_images( // em in non-font-size properties is relative to this element's // OWN computed font-size; resolve every relative length now. let em_px = style.font_size.unwrap_or(parent_fs); + if let Some(expression) = style.line_height_expression.as_deref() { + if let Some(resolved) = crate::style::resolve_contextual_length( + expression, + em_px, + root_fs, + vw, + vh, + em_px, + ) { + style.line_height = Some( + if crate::style::line_height_expression_is_length( + expression, + ) { + crate::LineHeight::Px(resolved) + } else { + crate::LineHeight::Ratio(resolved) + }, + ); + } + } else if let Some(crate::LineHeight::Relative(relative)) = + style.line_height + { + let pixels = match relative { + crate::Dimension::Percent(percent) => em_px * percent, + dimension => match dimension.resolve( + em_px, + root_fs, + vw, + vh, + ) { + crate::Dimension::Px(pixels) => pixels, + _ => em_px, + }, + }; + style.line_height = Some(crate::LineHeight::Px(pixels)); + } let cb_w = inh.cb_width; for index in 0..6 { let Some(expression) = style.size_expressions[index].as_deref() else { diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 1de8a8a..790d155 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -103,6 +103,13 @@ pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 { match style.line_height { Some(crate::LineHeight::Px(px)) => px, Some(crate::LineHeight::Ratio(ratio)) => font_size * ratio, + Some(crate::LineHeight::Relative(relative)) => match relative { + crate::Dimension::Percent(percent) => font_size * percent, + dimension => match dimension.resolve(font_size, 16.0, 0.0, 0.0) { + crate::Dimension::Px(px) => px, + _ => font_size, + }, + }, None | Some(crate::LineHeight::Normal) => normal_line_height( font_size, resolve_font_family(style.font_family.as_deref()), diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 5ffcb0f..864f329 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -251,6 +251,12 @@ pub struct LayoutStyle { /// o'clock per CSS, list of (rgba, optional 0..1 stop position)). Modern /// hero sections use gradients heavily; without this they paint white. pub background_gradient: Option<(f32, Vec<([u8; 4], Option)>)>, + /// `conic-gradient(...)` background. The angle is the CSS `from` angle, + /// the center is a fraction of the border box, and stops are normalized + /// during paint. Conic gradients commonly provide the color source for a + /// repeated SVG mask in modern hero artwork. + pub background_conic_gradient: + Option<(f32, (f32, f32), Vec<([u8; 4], Option)>)>, /// The first `url(...)` reference from `background`/`background-image` /// (gradients and repeat keywords in the same shorthand are ignored: we /// paint the referenced image, not the gradient layer). @@ -279,6 +285,12 @@ pub struct LayoutStyle { /// `background-color`/`color` instead of carrying its own colors). Without /// this, every such icon paints as a solid filled square. pub mask_image: Option, + /// Explicit `mask-size` / `-webkit-mask-size` in CSS px. + pub mask_size: Option<(f32, f32)>, + /// Explicit `(repeat-x, repeat-y)` choice. `None` retains the CSS default + /// (`repeat` on both axes) when an explicit tile size exists, while + /// preserving the legacy fill-box fallback for unsized icon masks. + pub mask_repeat: Option<(bool, bool)>, /// Foreground (text) color for the paint step. pub color: Option<[u8; 4]>, pub border_color: Option<[u8; 4]>, @@ -287,6 +299,11 @@ pub struct LayoutStyle { /// `font_size` (px) during the inheritance pass against the parent and /// root font-sizes. `None` when font-size was absolute or unset. pub font_size_raw: Option, + /// Deferred functional `font-size` (`clamp()`, `min()`, `max()`, `calc()`). + /// These expressions must see the live viewport and parent font size; + /// eagerly treating `9vw` as the number 9 made responsive headings pin to + /// the minimum arm of their clamp. + pub font_size_expression: Option, pub font_weight: Option, /// The computed `font-family` list, lowercased. Inherited. The text engine /// resolves it to a bundled face (Liberation Sans/Serif/Mono) the way @@ -444,6 +461,9 @@ pub struct LayoutStyle { /// shaped text (a fixed ratio made real-site prose noticeably tighter than /// Chromium). pub line_height: Option, + /// Deferred functional line-height (`calc()`, `min()`, `clamp()`) resolved + /// after the element font and live viewport are known. + pub line_height_expression: Option, /// `text-transform`. Inherited. Applied to span text before shaping. pub text_transform: Option, @@ -576,8 +596,13 @@ pub enum Clear { #[derive(Debug, Clone, Copy, PartialEq)] pub enum LineHeight { Normal, + /// Unitless number. Unlike length/percentage values, this remains a ratio + /// when inherited and therefore scales with each descendant's font size. Ratio(f32), Px(f32), + /// A specified length or percentage awaiting computed-value resolution. + /// It becomes `Px` on the declaring element before inheritance. + Relative(Dimension), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index c403c1e..100bafd 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -181,6 +181,16 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - if let Some(path) = bg_path() { paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops); } + } else if let Some((angle, center, stops)) = + &style.background_conic_gradient + { + paint_conic_gradient( + &mut pixmap, + &visible_rect, + *angle, + *center, + stops, + ); } else if let Some(bg) = style.background_color { // A masked element's background-color is the mask's fill color, // not an ordinary box background (handled below), so this only @@ -196,7 +206,18 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - if let Some(mask_url) = &style.mask_image { let fill = style.background_color.or(style.color).unwrap_or([0, 0, 0, 255]); - paint_mask(mask_url, base_url, &visible_rect, fill, &mut pixmap, &mut image_cache); + paint_mask( + mask_url, + base_url, + &visible_rect, + fill, + style.background_gradient.as_ref(), + style.background_conic_gradient.as_ref(), + style.mask_size, + style.mask_repeat, + &mut pixmap, + &mut image_cache, + ); } else if let Some(bg_url) = &style.background_image { // With an explicit background-size, paint the image at that size // positioned within the box per background-position, instead of @@ -927,6 +948,156 @@ fn paint_linear_gradient(pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &cra } } +fn paint_conic_gradient( + pixmap: &mut Pixmap, + rect: &crate::Rect, + angle: f32, + center: (f32, f32), + stops: &[([u8; 4], Option)], +) { + if rect.width <= 0.0 || rect.height <= 0.0 || stops.len() < 2 { + return; + } + let width = rect.width.ceil() as u32; + let height = rect.height.ceil() as u32; + let Some(mut layer) = Pixmap::new(width, height) else { + return; + }; + let normalized = normalized_stops(stops); + for y in 0..height { + for x in 0..width { + let color = conic_color_at( + rect, + angle, + center, + &normalized, + rect.x + x as f32 + 0.5, + rect.y + y as f32 + 0.5, + ); + layer.pixels_mut()[(y * width + x) as usize] = premultiplied(color); + } + } + pixmap.draw_pixmap( + rect.x.floor() as i32, + rect.y.floor() as i32, + layer.as_ref(), + &tiny_skia::PixmapPaint::default(), + Transform::identity(), + None, + ); +} + +fn normalized_stops( + stops: &[([u8; 4], Option)], +) -> Vec<(f32, [u8; 4])> { + let count = stops.len(); + let mut normalized = Vec::with_capacity(count); + let mut last = 0.0f32; + for (index, (color, position)) in stops.iter().enumerate() { + let position = position + .unwrap_or_else(|| { + if count <= 1 { + 0.0 + } else { + index as f32 / (count - 1) as f32 + } + }) + .clamp(0.0, 1.0) + .max(last); + last = position; + normalized.push((position, *color)); + } + normalized +} + +fn sample_normalized_stops(stops: &[(f32, [u8; 4])], t: f32) -> [u8; 4] { + let t = t.clamp(0.0, 1.0); + let Some(&(first_position, first_color)) = stops.first() else { + return [0, 0, 0, 0]; + }; + if t <= first_position { + return first_color; + } + for pair in stops.windows(2) { + let (start_position, start_color) = pair[0]; + let (end_position, end_color) = pair[1]; + if t <= end_position { + let span = end_position - start_position; + let fraction = if span <= f32::EPSILON { + 1.0 + } else { + ((t - start_position) / span).clamp(0.0, 1.0) + }; + let interpolate = |start: u8, end: u8| { + (start as f32 + (end as f32 - start as f32) * fraction) + .round() + .clamp(0.0, 255.0) as u8 + }; + return [ + interpolate(start_color[0], end_color[0]), + interpolate(start_color[1], end_color[1]), + interpolate(start_color[2], end_color[2]), + interpolate(start_color[3], end_color[3]), + ]; + } + } + stops.last().map(|(_, color)| *color).unwrap_or(first_color) +} + +fn conic_color_at( + rect: &crate::Rect, + angle: f32, + center: (f32, f32), + stops: &[(f32, [u8; 4])], + x: f32, + y: f32, +) -> [u8; 4] { + let center_x = rect.x + rect.width * center.0; + let center_y = rect.y + rect.height * center.1; + let point_angle = (x - center_x) + .atan2(-(y - center_y)) + .to_degrees() + .rem_euclid(360.0); + let position = (point_angle - angle).rem_euclid(360.0) / 360.0; + sample_normalized_stops(stops, position) +} + +fn linear_color_at( + rect: &crate::Rect, + angle: f32, + stops: &[(f32, [u8; 4])], + x: f32, + y: f32, +) -> [u8; 4] { + let radians = angle.to_radians(); + let dx = radians.sin(); + let dy = -radians.cos(); + let center_x = rect.x + rect.width / 2.0; + let center_y = rect.y + rect.height / 2.0; + let half = (dx.abs() * rect.width + dy.abs() * rect.height) / 2.0; + if half <= f32::EPSILON { + return sample_normalized_stops(stops, 0.5); + } + let start_x = center_x - dx * half; + let start_y = center_y - dy * half; + let position = ((x - start_x) * dx + (y - start_y) * dy) / (2.0 * half); + sample_normalized_stops(stops, position) +} + +fn premultiplied(color: [u8; 4]) -> tiny_skia::PremultipliedColorU8 { + let alpha = color[3] as u32; + tiny_skia::PremultipliedColorU8::from_rgba( + ((color[0] as u32 * alpha) / 255) as u8, + ((color[1] as u32 * alpha) / 255) as u8, + ((color[2] as u32 * alpha) / 255) as u8, + color[3], + ) + .unwrap_or_else(|| { + tiny_skia::PremultipliedColorU8::from_rgba(0, 0, 0, 0) + .expect("transparent premultiplied color") + }) +} + fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() @@ -1744,6 +1915,10 @@ fn paint_mask( base_url: Option<&str>, rect: &crate::Rect, fill: [u8; 4], + linear_gradient: Option<&(f32, Vec<([u8; 4], Option)>)>, + conic_gradient: Option<&(f32, (f32, f32), Vec<([u8; 4], Option)>)>, + mask_size: Option<(f32, f32)>, + mask_repeat: Option<(bool, bool)>, pixmap: &mut Pixmap, cache: &mut std::collections::HashMap>>, ) -> bool { @@ -1751,14 +1926,70 @@ fn paint_mask( return false; } let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false }; - let (w, h) = (rect.width as u32, rect.height as u32); - let mask = if is_svg(&bytes) { render_svg(&bytes, w, h) } else { raster_to_pixmap(&bytes, w, h) }; + let (box_width, box_height) = (rect.width.ceil() as u32, rect.height.ceil() as u32); + let (tile_width, tile_height) = mask_size + .map(|(width, height)| { + ( + width.max(1.0).ceil() as u32, + height.max(1.0).ceil() as u32, + ) + }) + .unwrap_or((box_width, box_height)); + let mask = if is_svg(&bytes) { + render_svg(&bytes, tile_width, tile_height) + } else { + raster_to_pixmap(&bytes, tile_width, tile_height) + }; let Some(mask) = mask else { return false }; - let recolored = recolor_by_alpha(&mask, fill); + let repeat = if mask_size.is_some() { + mask_repeat.unwrap_or((true, true)) + } else { + mask_repeat.unwrap_or((false, false)) + }; + let normalized_linear = + linear_gradient.map(|(_, stops)| normalized_stops(stops)); + let normalized_conic = + conic_gradient.map(|(_, _, stops)| normalized_stops(stops)); + let Some(mut recolored) = Pixmap::new(box_width, box_height) else { + return false; + }; + for y in 0..box_height { + if !repeat.1 && y >= tile_height { + continue; + } + let tile_y = if repeat.1 { y % tile_height } else { y }; + for x in 0..box_width { + if !repeat.0 && x >= tile_width { + continue; + } + let tile_x = if repeat.0 { x % tile_width } else { x }; + let coverage = + mask.pixels()[(tile_y * tile_width + tile_x) as usize].alpha() as u32; + if coverage == 0 { + continue; + } + let sample_x = rect.x + x as f32 + 0.5; + let sample_y = rect.y + y as f32 + 0.5; + let mut color = if let (Some((angle, center, _)), Some(stops)) = + (conic_gradient, normalized_conic.as_deref()) + { + conic_color_at(rect, *angle, *center, stops, sample_x, sample_y) + } else if let (Some((angle, _)), Some(stops)) = + (linear_gradient, normalized_linear.as_deref()) + { + linear_color_at(rect, *angle, stops, sample_x, sample_y) + } else { + fill + }; + color[3] = ((color[3] as u32 * coverage) / 255) as u8; + recolored.pixels_mut()[(y * box_width + x) as usize] = + premultiplied(color); + } + } pixmap.draw_pixmap( - rect.x as i32, - rect.y as i32, + rect.x.floor() as i32, + rect.y.floor() as i32, recolored.as_ref(), &tiny_skia::PixmapPaint::default(), Transform::identity(), @@ -1767,27 +1998,6 @@ fn paint_mask( true } -/// Replace every pixel's color with `fill`, scaling `fill`'s alpha by the -/// source pixel's own alpha (its mask coverage at that point). -fn recolor_by_alpha(src: &Pixmap, fill: [u8; 4]) -> Pixmap { - let (w, h) = (src.width(), src.height()); - let mut out = Pixmap::new(w, h).expect("non-zero size, already validated by caller"); - let dst = out.pixels_mut(); - for (i, p) in src.pixels().iter().enumerate() { - let coverage = p.alpha() as u32; - if coverage == 0 { - continue; - } - let a = (fill[3] as u32 * coverage) / 255; - let r = (fill[0] as u32 * a) / 255; - let g = (fill[1] as u32 * a) / 255; - let b = (fill[2] as u32 * a) / 255; - dst[i] = tiny_skia::PremultipliedColorU8::from_rgba(r as u8, g as u8, b as u8, a as u8) - .unwrap_or_else(|| tiny_skia::PremultipliedColorU8::from_rgba(0, 0, 0, 0).unwrap()); - } - out -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 4733925..ceb5f22 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -426,7 +426,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { if !value.trim().is_empty() { style.background_color = None; style.background_gradient = parse_linear_gradient(value); - if style.background_gradient.is_none() { + style.background_conic_gradient = parse_conic_gradient(value); + if style.background_gradient.is_none() + && style.background_conic_gradient.is_none() + { style.background_color = parse_color(value); } style.background_image = parse_url(value); @@ -437,11 +440,24 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "background-image" => { style.background_gradient = parse_linear_gradient(value); + style.background_conic_gradient = parse_conic_gradient(value); style.background_image = parse_url(value); } "background-size" => style.background_size = parse_background_size(value), "background-position" => style.background_position = parse_background_position(value), "mask-image" | "-webkit-mask-image" => style.mask_image = parse_url(value), + "mask-size" | "-webkit-mask-size" => { + style.mask_size = parse_background_size(value) + } + "mask-repeat" | "-webkit-mask-repeat" => { + style.mask_repeat = match value.trim() { + "repeat" | "space" | "round" => Some((true, true)), + "repeat-x" => Some((true, false)), + "repeat-y" => Some((false, true)), + "no-repeat" => Some((false, false)), + _ => None, + } + } "background-clip" | "-webkit-background-clip" => { // `text` clips the background to the element's glyphs (the gradient/ // solid-color text technique). Any box value (border-box/padding-box/ @@ -668,15 +684,30 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "line-height" => { let v = value.trim(); + if v.contains('(') { + style.line_height = None; + style.line_height_expression = Some(v.to_string()); + return; + } + style.line_height_expression = None; style.line_height = if v.eq_ignore_ascii_case("normal") { Some(crate::LineHeight::Normal) } else if let Some(pct) = v.strip_suffix('%') { - pct.trim().parse::().ok().map(|n| crate::LineHeight::Ratio(n / 100.0)) - } else if v.ends_with("rem") || v.ends_with("em") { - // em/rem in line-height are relative to font-size, i.e. a ratio. - v.trim_end_matches("rem").trim_end_matches("em").trim().parse::().ok().map(crate::LineHeight::Ratio) + pct.trim() + .parse::() + .ok() + .map(|number| { + crate::LineHeight::Relative(crate::Dimension::Percent( + number / 100.0, + )) + }) } else if v.ends_with("px") || v.ends_with("pt") { px_value(v).map(crate::LineHeight::Px) + } else if ["rem", "em", "ex", "vw", "vh", "vmin", "vmax"] + .iter() + .any(|unit| v.ends_with(unit)) + { + Some(crate::LineHeight::Relative(dimension_value(v))) } else { // Unitless number: a multiple of font-size (the common case). v.parse::().ok().map(crate::LineHeight::Ratio) @@ -748,6 +779,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "grid-column" => set_grid_placement(style, value, true), "grid-row" => set_grid_placement(style, value, false), + "grid-column-start" => set_grid_placement_side(style, value, true, true), + "grid-column-end" => set_grid_placement_side(style, value, true, false), + "grid-row-start" => set_grid_placement_side(style, value, false, true), + "grid-row-end" => set_grid_placement_side(style, value, false, false), "transform" => parse_transform(style, value), "filter" => set_containing_block_trigger( style, @@ -1320,6 +1355,64 @@ fn set_grid_placement(style: &mut LayoutStyle, value: &str, is_col: bool) { } } +/// Apply one grid-placement longhand without clearing the opposite side. +/// Responsive grid systems commonly establish a default span with +/// `.layout > * { grid-column-end: span 4 }` and override only the start/end +/// on selected children; dropping these longhands traps every item in one +/// auto-placed track. +fn set_grid_placement_side( + style: &mut LayoutStyle, + value: &str, + is_col: bool, + is_start: bool, +) { + if grid_line_has_name(value) { + let raw_slot = if is_col { + &mut style.grid_column_raw + } else { + &mut style.grid_row_raw + }; + let (mut start, mut end) = raw_slot + .as_deref() + .and_then(|raw| raw.split_once('/')) + .map(|(start, end)| { + (start.trim().to_string(), end.trim().to_string()) + }) + .unwrap_or_else(|| ("auto".to_string(), "auto".to_string())); + if is_start { + start = value.trim().to_string(); + } else { + end = value.trim().to_string(); + } + *raw_slot = Some(format!("{start} / {end}")); + if is_col { + style.grid_column = None; + } else { + style.grid_row = None; + } + return; + } + + let placement = parse_grid_placement(value); + let line_slot = if is_col { + style.grid_column_raw = None; + &mut style.grid_column + } else { + style.grid_row_raw = None; + &mut style.grid_row + }; + let mut line = line_slot.clone().unwrap_or(taffy::Line { + start: taffy::GridPlacement::Auto, + end: taffy::GridPlacement::Auto, + }); + if is_start { + line.start = placement; + } else { + line.end = placement; + } + *line_slot = Some(line); +} + /// True when a `grid-column`/`grid-row` value references a named line (any /// alphabetic token that is not a bare `span ` count), so it must defer to /// the parent's line-name map. @@ -1333,23 +1426,30 @@ fn grid_line_has_name(value: &str) -> bool { /// Parse `grid-column`/`grid-row` values: `2`, `1 / 3`, `span 2`. fn parse_grid_line(value: &str) -> Option> { - let place = |tok: &str| -> taffy::GridPlacement { - let tok = tok.trim(); - if let Some(n) = tok.strip_prefix("span").map(|s| s.trim()) { - if let Ok(s) = n.parse::() { return taffy::style_helpers::span(s); } - } - if let Ok(i) = tok.parse::() { return taffy::style_helpers::line(i); } - taffy::GridPlacement::Auto - }; let (a, b) = match value.split_once('/') { Some((a, b)) => (a, Some(b)), None => (value, None), }; - let start = place(a); - let end = b.map(place).unwrap_or(taffy::GridPlacement::Auto); + let start = parse_grid_placement(a); + let end = b + .map(parse_grid_placement) + .unwrap_or(taffy::GridPlacement::Auto); Some(taffy::Line { start, end }) } +fn parse_grid_placement(value: &str) -> taffy::GridPlacement { + let value = value.trim(); + if let Some(span) = value.strip_prefix("span").map(str::trim) { + if let Ok(span) = span.parse::() { + return taffy::style_helpers::span(span); + } + } + if let Ok(line) = value.parse::() { + return taffy::style_helpers::line(line); + } + taffy::GridPlacement::Auto +} + /// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex, /// rgb()/rgba(), `var(--x, fallback)` (uses the fallback), and a set of named /// colors. Returns None for anything else (transparent). @@ -2174,6 +2274,13 @@ fn font_size_keyword(v: &str) -> Option { } fn apply_font_size(style: &mut LayoutStyle, value: &str) { + if value.trim().contains('(') { + style.font_size = None; + style.font_size_raw = None; + style.font_size_expression = Some(value.trim().to_string()); + return; + } + style.font_size_expression = None; match dimension_value(value) { crate::Dimension::Px(p) => { style.font_size = Some(p); @@ -2194,6 +2301,14 @@ fn apply_font_size(style: &mut LayoutStyle, value: &str) { } } +pub(crate) fn line_height_expression_is_length(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains('%') + || ["px", "pt", "pc", "in", "cm", "mm", "rem", "em", "ex", "vw", "vh", "vmin", "vmax"] + .iter() + .any(|unit| lower.contains(unit)) +} + /// Parse the layout-relevant portion of the CSS `font` shorthand: /// `[style || variant || weight || stretch]? size [/ line-height]? family`. /// @@ -2237,6 +2352,7 @@ fn apply_font_shorthand(style: &mut LayoutStyle, value: &str) { style.font_style_italic = Some(false); style.font_weight = Some("normal".to_string()); style.line_height = Some(crate::LineHeight::Normal); + style.line_height_expression = None; for token in &tokens[..size_index] { let lower = token.to_ascii_lowercase(); if lower == "italic" || lower.starts_with("oblique") { @@ -2605,6 +2721,115 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option) Some((angle, stops)) } +/// Parse the common `conic-gradient(from A at X Y, color P%, ...)` form. +/// Angles follow CSS convention (0deg at 12 o'clock, clockwise); the center +/// is retained as box-relative fractions for paint-time resolution. +fn parse_conic_gradient( + value: &str, +) -> Option<(f32, (f32, f32), Vec<([u8; 4], Option)>)> { + let v = value.trim(); + let lower = v.to_ascii_lowercase(); + let start = lower.find("conic-gradient(")?; + let open = start + "conic-gradient(".len(); + let end = find_matching_paren(&v[open..])? + open; + let inner = &v[open..end]; + let parts = split_top_level(inner, ','); + if parts.is_empty() { + return None; + } + + let mut angle = 0.0f32; + let mut center = (0.5f32, 0.5f32); + let mut stop_start = 0usize; + let prelude = parts[0].trim().to_ascii_lowercase(); + if prelude.starts_with("from ") || prelude.starts_with("at ") { + if let Some(from) = prelude.find("from ") { + let token = prelude[from + 5..] + .split_whitespace() + .next() + .unwrap_or_default(); + angle = parse_css_angle(token).unwrap_or(0.0).rem_euclid(360.0); + } + if let Some(at) = prelude.find(" at ") { + let coords: Vec<&str> = prelude[at + 4..].split_whitespace().collect(); + if let Some(x) = coords.first().and_then(|value| percent_fraction(value)) { + center.0 = x; + } + if let Some(y) = coords.get(1).and_then(|value| percent_fraction(value)) { + center.1 = y; + } + } else if let Some(at) = prelude.strip_prefix("at ") { + let coords: Vec<&str> = at.split_whitespace().collect(); + if let Some(x) = coords.first().and_then(|value| percent_fraction(value)) { + center.0 = x; + } + if let Some(y) = coords.get(1).and_then(|value| percent_fraction(value)) { + center.1 = y; + } + } + stop_start = 1; + } + + let mut stops = Vec::new(); + for part in &parts[stop_start..] { + let part = part.trim(); + if part.is_empty() { + continue; + } + let (color, position) = split_color_stop(part); + if let Some(color) = parse_color(color) { + stops.push((color, position)); + } + } + (stops.len() >= 2).then_some((angle, center, stops)) +} + +fn parse_css_angle(value: &str) -> Option { + let value = value.trim(); + if let Some(degrees) = value.strip_suffix("deg") { + return degrees.trim().parse::().ok(); + } + if let Some(turns) = value.strip_suffix("turn") { + return turns.trim().parse::().ok().map(|turns| turns * 360.0); + } + if let Some(gradians) = value.strip_suffix("grad") { + return gradians.trim().parse::().ok().map(|grad| grad * 0.9); + } + if let Some(radians) = value.strip_suffix("rad") { + return radians + .trim() + .parse::() + .ok() + .map(f32::to_degrees); + } + None +} + +fn split_color_stop(value: &str) -> (&str, Option) { + if let Some(idx) = value.rfind(char::is_whitespace) { + let tail = value[idx + 1..].trim(); + if let Some(percent) = tail + .strip_suffix('%') + .and_then(|number| number.parse::().ok()) + { + return ( + value[..idx].trim(), + Some((percent / 100.0).clamp(0.0, 1.0)), + ); + } + if let Some(degrees) = tail + .strip_suffix("deg") + .and_then(|number| number.parse::().ok()) + { + return ( + value[..idx].trim(), + Some((degrees / 360.0).clamp(0.0, 1.0)), + ); + } + } + (value, None) +} + /// Parse `aspect-ratio` to a width/height ratio. Accepts `16 / 9`, `1.5`, and /// the `auto ` form (the `auto` keyword alone yields `None`, meaning the /// intrinsic ratio, which for images is filled in at layout). @@ -2636,8 +2861,39 @@ fn parse_aspect_ratio(value: &str) -> Option { /// `no-repeat`, etc.): we paint the referenced image, not the gradient. fn parse_url(value: &str) -> Option { let start = value.find("url(")? + 4; - let end = value[start..].find(')')?; - let inner = value[start..start + end].trim(); + let mut depth = 1i32; + let mut quote: Option = None; + let mut escaped = false; + let mut end = None; + for (offset, character) in value[start..].char_indices() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if let Some(active) = quote { + if character == active { + quote = None; + } + continue; + } + match character { + '\'' | '"' => quote = Some(character), + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = Some(start + offset); + break; + } + } + _ => {} + } + } + let inner = value[start..end?].trim(); let unquoted = inner.trim_matches(|c| c == '"' || c == '\''); if unquoted.is_empty() { None @@ -3079,6 +3335,31 @@ mod tests { assert_eq!(shorthand.grid_template_columns.len(), 3); } + #[test] + fn grid_placement_longhands_preserve_the_opposite_side() { + let style = compute_style( + "div", + Some( + "grid-column-start:2;grid-column-end:span 4;\ + grid-row-start:3;grid-row-end:5", + ), + ); + assert_eq!( + style.grid_column, + Some(taffy::Line { + start: taffy::style_helpers::line(2), + end: taffy::style_helpers::span(4), + }) + ); + assert_eq!( + style.grid_row, + Some(taffy::Line { + start: taffy::style_helpers::line(3), + end: taffy::style_helpers::line(5), + }) + ); + } + #[test] fn contextual_css_math_uses_runtime_geometry() { let context = (20.0, 16.0, 9.0, 10.0, 900.0); @@ -3174,12 +3455,38 @@ mod tests { ); assert_eq!(s.background_color, None); assert_eq!(s.background_gradient, None); + assert_eq!(s.background_conic_gradient, None); assert_eq!(s.background_image, None); assert_eq!(s.background_size, None); assert_eq!(s.background_position, (0.0, 0.0)); assert!(!s.background_clip_text); } + #[test] + fn conic_background_and_repeated_data_svg_mask_are_preserved() { + let style = compute_style( + "div", + Some( + "background:conic-gradient(from 122deg at 50% 50%,\ + transparent 17%,#f627e3 25%,#6911d2 32%,transparent 91%);\ + mask-image:url(\"data:image/svg+xml,\ + \");\ + mask-size:22px 22px;mask-repeat:repeat", + ), + ); + let (angle, center, stops) = style + .background_conic_gradient + .expect("conic gradient should parse"); + assert_eq!(angle, 122.0); + assert_eq!(center, (0.5, 0.5)); + assert_eq!(stops.len(), 4); + assert_eq!(style.mask_size, Some((22.0, 22.0))); + assert_eq!(style.mask_repeat, Some((true, true))); + let mask = style.mask_image.expect("data SVG mask should parse"); + assert!(mask.ends_with("")); + assert!(mask.contains("rotate(-60)")); + } + #[test] fn important_and_auto() { let s = compute_style("div", Some("width: 100px !important; height: auto")); diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 649e68c..5ed3d71 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -907,3 +907,95 @@ fn right_float_navigation_shares_inline_band() { "right float order: {right_three:?} {right_two:?} {right_one:?}" ); } + +#[test] +fn functional_font_sizes_resolve_against_live_viewport() { + let tree = parse_html( + r#" + +

Features

+

Subtitle

+ "#, + ); + let wide = layout_dom(&tree, (900.0, 1000.0)); + let feature = tree.get_element_by_id("feature").unwrap(); + let subtitle = tree.get_element_by_id("subtitle").unwrap(); + assert!( + (wide.styles[&feature].font_size.unwrap() - 50.654).abs() < 0.05, + "wide feature font: {:?}", + wide.styles[&feature].font_size + ); + assert_eq!(wide.styles[&subtitle].font_size, Some(16.0)); + + let narrow = layout_dom(&tree, (320.0, 640.0)); + assert!( + (narrow.styles[&feature].font_size.unwrap() - 28.8).abs() < 0.05, + "narrow feature font: {:?}", + narrow.styles[&feature].font_size + ); + assert!( + (narrow.styles[&subtitle].font_size.unwrap() - 12.8).abs() < 0.05, + "narrow subtitle font: {:?}", + narrow.styles[&subtitle].font_size + ); +} + +#[test] +fn responsive_grid_child_spans_all_tracks_via_end_longhand() { + let tree = parse_html( + r#" + +
Full-width content
+ "#, + ); + let layout = layout_dom(&tree, (900.0, 1000.0)); + let hero = layout.rects[&tree.get_element_by_id("hero").unwrap()]; + assert!( + (hero.width - 800.0).abs() < 0.01, + "span-4 child should fill all four tracks: {hero:?}" + ); +} + +#[test] +fn length_line_height_computes_before_inheritance() { + let tree = parse_html( + r#" + +
Inherited length
+
Unitless ratio
+ "#, + ); + let layout = layout_dom(&tree, (900.0, 1000.0)); + let parent = tree.get_element_by_id("parent").unwrap(); + let child = tree.get_element_by_id("child").unwrap(); + let ratio = tree.get_element_by_id("ratio").unwrap(); + assert_eq!( + layout.styles[&parent].line_height, + Some(obscura_render::LineHeight::Px(80.0)) + ); + assert_eq!( + layout.styles[&child].line_height, + Some(obscura_render::LineHeight::Px(80.0)) + ); + assert_eq!( + layout.styles[&ratio].line_height, + Some(obscura_render::LineHeight::Ratio(2.0)) + ); +}