render: resolve modern responsive geometry

This commit is contained in:
SGavrl committed 2026-07-26 13:47:09 +00:00
1 parent 6eafb7d6cc
commit d9f9202737
7 files changed
+988 -88

No files matched your search

+239 -40
View File
@@ -505,6 +505,19 @@ pub(crate) fn media_query_applies(query: &str) -> bool {
pub(crate) fn media_query_applies_for_viewport( pub(crate) fn media_query_applies_for_viewport(
query: &str, query: &str,
viewport: (f32, f32), 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 { ) -> bool {
let viewport_w = viewport.0; let viewport_w = viewport.0;
let viewport_h = viewport.1; 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 // Width constraints, both `min-width:`/`max-width:` and the modern range
// forms `width>=Npx` / `(Npx<=width)`. // 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 { if viewport_w > px {
return false; 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 { if viewport_w < px {
return false; 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 { if viewport_w > px {
return false; 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 { if viewport_w < px {
return false; 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 { if viewport_w <= px {
return false; 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_length_before(&compact, ">width", viewport, LengthAxis::Width) {
if viewport_w >= px { if viewport_w >= px {
return false; 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 { if viewport_h > px {
return false; 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 { if viewport_h < px {
return false; 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 { if viewport_h > px {
return false; 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 { if viewport_h < px {
return false; 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 { if viewport_h <= px {
return false; 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;
}
}
if let Some(px) = extract_length_before(&compact, ">height", viewport, LengthAxis::Height) {
if viewport_h >= px { if viewport_h >= px {
return false; return false;
} }
@@ -818,47 +871,143 @@ fn split_selector_list(sel: &str) -> Vec<String> {
out out
} }
/// Read the number immediately following `prop` in `s`. Callers pass a #[derive(Clone, Copy)]
/// whitespace-stripped `s`, so the first non-digit character always ends the enum LengthAxis {
/// number. Handles a bare px value (`750px`) and the extremely common Width,
/// responsive-breakpoint idiom `calc(750px - 1px)` (real stylesheets use this Height,
/// 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, /// Read a CSS length immediately following `prop`. Media-query `em` and `rem`
/// never nested, multiplied, or mixed-unit. /// units resolve against the initial font size (16 CSS px), not an element's
fn extract_px(s: &str, prop: &str) -> Option<f32> { /// 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<f32> {
let start = s.find(prop)? + prop.len(); let start = s.find(prop)? + prop.len();
let rest = &s[start..]; let rest = &s[start..];
if let Some(inner) = rest.strip_prefix("calc(") { if let Some(inner) = rest.strip_prefix("calc(") {
let end = inner.find(')')?; 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(); parse_length_prefix(rest, viewport, axis)
num.parse::<f32>().ok()
} }
/// Sum a sequence of signed px terms like `1120px-1px` (whitespace already /// Read the length immediately before a range marker (`64rem<=width`).
/// stripped by the caller) left to right. fn extract_length_before(
fn eval_px_sum(expr: &str) -> f32 { s: &str,
marker: &str,
viewport: (f32, f32),
axis: LengthAxis,
) -> Option<f32> {
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<f32> {
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::<f32>().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<f32> {
let mut total = 0.0; let mut total = 0.0;
let mut sign = 1.0; let mut sign = 1.0;
let mut num = String::new(); let mut term = String::new();
let flush = |num: &mut String, sign: f32, total: &mut f32| { let flush = |term: &mut String, sign: f32, total: &mut f32| -> Option<()> {
if let Ok(v) = num.parse::<f32>() { let value = parse_length_prefix(term, viewport, axis)?;
*total += sign * v; *total += sign * value;
} term.clear();
num.clear(); Some(())
}; };
for c in expr.chars() { for (idx, c) in expr.char_indices() {
match c { match c {
'+' => { flush(&mut num, sign, &mut total); sign = 1.0; } '+' if idx > 0 => {
'-' => { flush(&mut num, sign, &mut total); sign = -1.0; } flush(&mut term, sign, &mut total)?;
c if c.is_ascii_digit() || c == '.' => num.push(c), sign = 1.0;
_ => {} // unit suffix (px) or anything else: not part of the number }
'-' if idx > 0 => {
flush(&mut term, sign, &mut total)?;
sign = -1.0;
}
_ => term.push(c),
} }
} }
flush(&mut num, sign, &mut total); flush(&mut term, sign, &mut total)?;
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<char> = 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)] #[cfg(test)]
@@ -910,4 +1059,54 @@ mod tests {
.any(|(_, declarations)| declarations.contains("width:100%")) .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")));
}
} }
+64 -4
View File
@@ -527,9 +527,24 @@ pub fn layout_dom_with_images(
let vh = viewport.1 / 100.0; let vh = viewport.1 / 100.0;
let root_fs = { let root_fs = {
let s = styles.get(&root_id); let s = styles.get(&root_id);
match (s.and_then(|s| s.font_size), s.and_then(|s| s.font_size_raw)) { match (
(Some(px), _) => px, s.and_then(|s| s.font_size),
(None, Some(d)) => match d.resolve(16.0, 16.0, vw, vh) { 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::Px(p) => p,
crate::Dimension::Percent(p) => 16.0 * p, crate::Dimension::Percent(p) => 16.0 * p,
_ => 16.0, _ => 16.0,
@@ -554,7 +569,16 @@ pub fn layout_dom_with_images(
// Resolve a relative font-size against the PARENT (em/%) or // Resolve a relative font-size against the PARENT (em/%) or
// ROOT (rem) font-size before inheriting it downward. // ROOT (rem) font-size before inheriting it downward.
let parent_fs = inh.font_size.unwrap_or(16.0); 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 { let resolved = match raw {
crate::Dimension::Percent(p) => parent_fs * p, crate::Dimension::Percent(p) => parent_fs * p,
crate::Dimension::Em(v) => parent_fs * v, 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 // em in non-font-size properties is relative to this element's
// OWN computed font-size; resolve every relative length now. // OWN computed font-size; resolve every relative length now.
let em_px = style.font_size.unwrap_or(parent_fs); 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; let cb_w = inh.cb_width;
for index in 0..6 { for index in 0..6 {
let Some(expression) = style.size_expressions[index].as_deref() else { let Some(expression) = style.size_expressions[index].as_deref() else {
+7
View File
@@ -103,6 +103,13 @@ pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
match style.line_height { match style.line_height {
Some(crate::LineHeight::Px(px)) => px, Some(crate::LineHeight::Px(px)) => px,
Some(crate::LineHeight::Ratio(ratio)) => font_size * ratio, 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( None | Some(crate::LineHeight::Normal) => normal_line_height(
font_size, font_size,
resolve_font_family(style.font_family.as_deref()), resolve_font_family(style.font_family.as_deref()),
+25
View File
@@ -251,6 +251,12 @@ pub struct LayoutStyle {
/// o'clock per CSS, list of (rgba, optional 0..1 stop position)). Modern /// o'clock per CSS, list of (rgba, optional 0..1 stop position)). Modern
/// hero sections use gradients heavily; without this they paint white. /// hero sections use gradients heavily; without this they paint white.
pub background_gradient: Option<(f32, Vec<([u8; 4], Option<f32>)>)>, pub background_gradient: Option<(f32, Vec<([u8; 4], Option<f32>)>)>,
/// `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<f32>)>)>,
/// The first `url(...)` reference from `background`/`background-image` /// The first `url(...)` reference from `background`/`background-image`
/// (gradients and repeat keywords in the same shorthand are ignored: we /// (gradients and repeat keywords in the same shorthand are ignored: we
/// paint the referenced image, not the gradient layer). /// 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 /// `background-color`/`color` instead of carrying its own colors). Without
/// this, every such icon paints as a solid filled square. /// this, every such icon paints as a solid filled square.
pub mask_image: Option<String>, pub mask_image: Option<String>,
/// 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. /// Foreground (text) color for the paint step.
pub color: Option<[u8; 4]>, pub color: Option<[u8; 4]>,
pub border_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 /// `font_size` (px) during the inheritance pass against the parent and
/// root font-sizes. `None` when font-size was absolute or unset. /// root font-sizes. `None` when font-size was absolute or unset.
pub font_size_raw: Option<Dimension>, pub font_size_raw: Option<Dimension>,
/// 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<String>,
pub font_weight: Option<String>, pub font_weight: Option<String>,
/// The computed `font-family` list, lowercased. Inherited. The text engine /// The computed `font-family` list, lowercased. Inherited. The text engine
/// resolves it to a bundled face (Liberation Sans/Serif/Mono) the way /// 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 /// shaped text (a fixed ratio made real-site prose noticeably tighter than
/// Chromium). /// Chromium).
pub line_height: Option<LineHeight>, pub line_height: Option<LineHeight>,
/// Deferred functional line-height (`calc()`, `min()`, `clamp()`) resolved
/// after the element font and live viewport are known.
pub line_height_expression: Option<String>,
/// `text-transform`. Inherited. Applied to span text before shaping. /// `text-transform`. Inherited. Applied to span text before shaping.
pub text_transform: Option<TextTransform>, pub text_transform: Option<TextTransform>,
@@ -576,8 +596,13 @@ pub enum Clear {
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum LineHeight { pub enum LineHeight {
Normal, Normal,
/// Unitless number. Unlike length/percentage values, this remains a ratio
/// when inherited and therefore scales with each descendant's font size.
Ratio(f32), Ratio(f32),
Px(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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+237 -27
View File
@@ -181,6 +181,16 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
if let Some(path) = bg_path() { if let Some(path) = bg_path() {
paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops); 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 { } else if let Some(bg) = style.background_color {
// A masked element's background-color is the mask's fill color, // A masked element's background-color is the mask's fill color,
// not an ordinary box background (handled below), so this only // 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 { if let Some(mask_url) = &style.mask_image {
let fill = style.background_color.or(style.color).unwrap_or([0, 0, 0, 255]); 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 { } else if let Some(bg_url) = &style.background_image {
// With an explicit background-size, paint the image at that size // With an explicit background-size, paint the image at that size
// positioned within the box per background-position, instead of // 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<f32>)],
) {
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<f32>)],
) -> 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)> { fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(bytes)) image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format() .with_guessed_format()
@@ -1744,6 +1915,10 @@ fn paint_mask(
base_url: Option<&str>, base_url: Option<&str>,
rect: &crate::Rect, rect: &crate::Rect,
fill: [u8; 4], fill: [u8; 4],
linear_gradient: Option<&(f32, Vec<([u8; 4], Option<f32>)>)>,
conic_gradient: Option<&(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)>,
mask_size: Option<(f32, f32)>,
mask_repeat: Option<(bool, bool)>,
pixmap: &mut Pixmap, pixmap: &mut Pixmap,
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>, cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> bool { ) -> bool {
@@ -1751,14 +1926,70 @@ fn paint_mask(
return false; return false;
} }
let Some(bytes) = fetch_bytes(src, base_url, cache) else { 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 (box_width, box_height) = (rect.width.ceil() as u32, rect.height.ceil() as u32);
let mask = if is_svg(&bytes) { render_svg(&bytes, w, h) } else { raster_to_pixmap(&bytes, w, h) }; 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 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( pixmap.draw_pixmap(
rect.x as i32, rect.x.floor() as i32,
rect.y as i32, rect.y.floor() as i32,
recolored.as_ref(), recolored.as_ref(),
&tiny_skia::PixmapPaint::default(), &tiny_skia::PixmapPaint::default(),
Transform::identity(), Transform::identity(),
@@ -1767,27 +1998,6 @@ fn paint_mask(
true 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+324 -17
View File
@@ -426,7 +426,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
if !value.trim().is_empty() { if !value.trim().is_empty() {
style.background_color = None; style.background_color = None;
style.background_gradient = parse_linear_gradient(value); 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_color = parse_color(value);
} }
style.background_image = parse_url(value); style.background_image = parse_url(value);
@@ -437,11 +440,24 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
} }
"background-image" => { "background-image" => {
style.background_gradient = parse_linear_gradient(value); style.background_gradient = parse_linear_gradient(value);
style.background_conic_gradient = parse_conic_gradient(value);
style.background_image = parse_url(value); style.background_image = parse_url(value);
} }
"background-size" => style.background_size = parse_background_size(value), "background-size" => style.background_size = parse_background_size(value),
"background-position" => style.background_position = parse_background_position(value), "background-position" => style.background_position = parse_background_position(value),
"mask-image" | "-webkit-mask-image" => style.mask_image = parse_url(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" => { "background-clip" | "-webkit-background-clip" => {
// `text` clips the background to the element's glyphs (the gradient/ // `text` clips the background to the element's glyphs (the gradient/
// solid-color text technique). Any box value (border-box/padding-box/ // 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" => { "line-height" => {
let v = value.trim(); 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") { style.line_height = if v.eq_ignore_ascii_case("normal") {
Some(crate::LineHeight::Normal) Some(crate::LineHeight::Normal)
} else if let Some(pct) = v.strip_suffix('%') { } else if let Some(pct) = v.strip_suffix('%') {
pct.trim().parse::<f32>().ok().map(|n| crate::LineHeight::Ratio(n / 100.0)) pct.trim()
} else if v.ends_with("rem") || v.ends_with("em") { .parse::<f32>()
// em/rem in line-height are relative to font-size, i.e. a ratio. .ok()
v.trim_end_matches("rem").trim_end_matches("em").trim().parse::<f32>().ok().map(crate::LineHeight::Ratio) .map(|number| {
crate::LineHeight::Relative(crate::Dimension::Percent(
number / 100.0,
))
})
} else if v.ends_with("px") || v.ends_with("pt") { } else if v.ends_with("px") || v.ends_with("pt") {
px_value(v).map(crate::LineHeight::Px) 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 { } else {
// Unitless number: a multiple of font-size (the common case). // Unitless number: a multiple of font-size (the common case).
v.parse::<f32>().ok().map(crate::LineHeight::Ratio) v.parse::<f32>().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-column" => set_grid_placement(style, value, true),
"grid-row" => set_grid_placement(style, value, false), "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), "transform" => parse_transform(style, value),
"filter" => set_containing_block_trigger( "filter" => set_containing_block_trigger(
style, 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 /// True when a `grid-column`/`grid-row` value references a named line (any
/// alphabetic token that is not a bare `span <n>` count), so it must defer to /// alphabetic token that is not a bare `span <n>` count), so it must defer to
/// the parent's line-name map. /// 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`. /// Parse `grid-column`/`grid-row` values: `2`, `1 / 3`, `span 2`.
fn parse_grid_line(value: &str) -> Option<taffy::Line<taffy::GridPlacement>> { fn parse_grid_line(value: &str) -> Option<taffy::Line<taffy::GridPlacement>> {
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::<u16>() { return taffy::style_helpers::span(s); }
}
if let Ok(i) = tok.parse::<i16>() { return taffy::style_helpers::line(i); }
taffy::GridPlacement::Auto
};
let (a, b) = match value.split_once('/') { let (a, b) = match value.split_once('/') {
Some((a, b)) => (a, Some(b)), Some((a, b)) => (a, Some(b)),
None => (value, None), None => (value, None),
}; };
let start = place(a); let start = parse_grid_placement(a);
let end = b.map(place).unwrap_or(taffy::GridPlacement::Auto); let end = b
.map(parse_grid_placement)
.unwrap_or(taffy::GridPlacement::Auto);
Some(taffy::Line { start, end }) 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::<u16>() {
return taffy::style_helpers::span(span);
}
}
if let Ok(line) = value.parse::<i16>() {
return taffy::style_helpers::line(line);
}
taffy::GridPlacement::Auto
}
/// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex, /// 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 /// rgb()/rgba(), `var(--x, fallback)` (uses the fallback), and a set of named
/// colors. Returns None for anything else (transparent). /// colors. Returns None for anything else (transparent).
@@ -2174,6 +2274,13 @@ fn font_size_keyword(v: &str) -> Option<f32> {
} }
fn apply_font_size(style: &mut LayoutStyle, value: &str) { 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) { match dimension_value(value) {
crate::Dimension::Px(p) => { crate::Dimension::Px(p) => {
style.font_size = Some(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: /// Parse the layout-relevant portion of the CSS `font` shorthand:
/// `[style || variant || weight || stretch]? size [/ line-height]? family`. /// `[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_style_italic = Some(false);
style.font_weight = Some("normal".to_string()); style.font_weight = Some("normal".to_string());
style.line_height = Some(crate::LineHeight::Normal); style.line_height = Some(crate::LineHeight::Normal);
style.line_height_expression = None;
for token in &tokens[..size_index] { for token in &tokens[..size_index] {
let lower = token.to_ascii_lowercase(); let lower = token.to_ascii_lowercase();
if lower == "italic" || lower.starts_with("oblique") { if lower == "italic" || lower.starts_with("oblique") {
@@ -2605,6 +2721,115 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option<f32>)
Some((angle, stops)) 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<f32>)>)> {
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<f32> {
let value = value.trim();
if let Some(degrees) = value.strip_suffix("deg") {
return degrees.trim().parse::<f32>().ok();
}
if let Some(turns) = value.strip_suffix("turn") {
return turns.trim().parse::<f32>().ok().map(|turns| turns * 360.0);
}
if let Some(gradians) = value.strip_suffix("grad") {
return gradians.trim().parse::<f32>().ok().map(|grad| grad * 0.9);
}
if let Some(radians) = value.strip_suffix("rad") {
return radians
.trim()
.parse::<f32>()
.ok()
.map(f32::to_degrees);
}
None
}
fn split_color_stop(value: &str) -> (&str, Option<f32>) {
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::<f32>().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::<f32>().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 /// Parse `aspect-ratio` to a width/height ratio. Accepts `16 / 9`, `1.5`, and
/// the `auto <ratio>` form (the `auto` keyword alone yields `None`, meaning the /// the `auto <ratio>` form (the `auto` keyword alone yields `None`, meaning the
/// intrinsic ratio, which for images is filled in at layout). /// intrinsic ratio, which for images is filled in at layout).
@@ -2636,8 +2861,39 @@ fn parse_aspect_ratio(value: &str) -> Option<f32> {
/// `no-repeat`, etc.): we paint the referenced image, not the gradient. /// `no-repeat`, etc.): we paint the referenced image, not the gradient.
fn parse_url(value: &str) -> Option<String> { fn parse_url(value: &str) -> Option<String> {
let start = value.find("url(")? + 4; let start = value.find("url(")? + 4;
let end = value[start..].find(')')?; let mut depth = 1i32;
let inner = value[start..start + end].trim(); let mut quote: Option<char> = 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 == '\''); let unquoted = inner.trim_matches(|c| c == '"' || c == '\'');
if unquoted.is_empty() { if unquoted.is_empty() {
None None
@@ -3079,6 +3335,31 @@ mod tests {
assert_eq!(shorthand.grid_template_columns.len(), 3); 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] #[test]
fn contextual_css_math_uses_runtime_geometry() { fn contextual_css_math_uses_runtime_geometry() {
let context = (20.0, 16.0, 9.0, 10.0, 900.0); 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_color, None);
assert_eq!(s.background_gradient, None); assert_eq!(s.background_gradient, None);
assert_eq!(s.background_conic_gradient, None);
assert_eq!(s.background_image, None); assert_eq!(s.background_image, None);
assert_eq!(s.background_size, None); assert_eq!(s.background_size, None);
assert_eq!(s.background_position, (0.0, 0.0)); assert_eq!(s.background_position, (0.0, 0.0));
assert!(!s.background_clip_text); 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,<svg viewBox='0 0 72 72'>\
<g transform='translate(36 36) rotate(-60)'></g></svg>\");\
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("</svg>"));
assert!(mask.contains("rotate(-60)"));
}
#[test] #[test]
fn important_and_auto() { fn important_and_auto() {
let s = compute_style("div", Some("width: 100px !important; height: auto")); let s = compute_style("div", Some("width: 100px !important; height: auto"));
@@ -907,3 +907,95 @@ fn right_float_navigation_shares_inline_band() {
"right float order: {right_three:?} {right_two:?} {right_one:?}" "right float order: {right_three:?} {right_two:?} {right_one:?}"
); );
} }
#[test]
fn functional_font_sizes_resolve_against_live_viewport() {
let tree = parse_html(
r#"
<style>
#feature { font-size: clamp(1rem, 9vw, 38pt) }
#subtitle { font-size: clamp(.5rem, 4vw, 1rem) }
</style>
<h2 id="feature">Features</h2>
<h3 id="subtitle">Subtitle</h3>
"#,
);
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#"
<style>
.layout {
display: grid;
width: 800px;
grid-template-columns: repeat(4, 1fr);
column-gap: 20px;
}
@media (max-width: 1007px) {
.layout > * { grid-column-end: span 4 }
}
</style>
<div class="layout"><section id="hero">Full-width content</section></div>
"#,
);
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#"
<style>
#parent { font-size: 64px; line-height: 5rem }
#child { font-size: 20px }
#ratio { font-size: 20px; line-height: 2 }
</style>
<div id="parent"><span id="child">Inherited length</span></div>
<div id="ratio">Unitless ratio</div>
"#,
);
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))
);
}