render: resolve modern responsive geometry
This commit is contained in:
1 parent
6eafb7d6cc
commit
d9f9202737
7 files changed
+988
-88
No files matched your search
@@ -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_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;
|
||||
}
|
||||
}
|
||||
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<String> {
|
||||
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<f32> {
|
||||
#[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<f32> {
|
||||
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::<f32>().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<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 sign = 1.0;
|
||||
let mut num = String::new();
|
||||
let flush = |num: &mut String, sign: f32, total: &mut f32| {
|
||||
if let Ok(v) = num.parse::<f32>() {
|
||||
*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<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)]
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<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`
|
||||
/// (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<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.
|
||||
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<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>,
|
||||
/// 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<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.
|
||||
pub text_transform: Option<TextTransform>,
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<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)> {
|
||||
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<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,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> 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::*;
|
||||
|
||||
@@ -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::<f32>().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::<f32>().ok().map(crate::LineHeight::Ratio)
|
||||
pct.trim()
|
||||
.parse::<f32>()
|
||||
.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::<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-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 <n>` 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<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('/') {
|
||||
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::<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,
|
||||
/// 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<f32> {
|
||||
}
|
||||
|
||||
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<f32>)
|
||||
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
|
||||
/// the `auto <ratio>` 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<f32> {
|
||||
/// `no-repeat`, etc.): we paint the referenced image, not the gradient.
|
||||
fn parse_url(value: &str) -> Option<String> {
|
||||
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<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 == '\'');
|
||||
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,<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]
|
||||
fn important_and_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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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))
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user