render: unify responsive geometry and inline gradients
This commit is contained in:
1 parent
29d0bce3f5
commit
3618549842
16 files changed
+587
-102
No files matched your search
@@ -45,6 +45,18 @@ impl Stylesheet {
|
||||
/// Parse and index a set of raw CSS sources (the text of each `<style>`
|
||||
/// block, in document order). Selectors that fail to parse are dropped.
|
||||
pub fn parse(tree: &DomTree, sources: &[String]) -> Self {
|
||||
Self::parse_for_viewport(tree, sources, (1280.0, 720.0))
|
||||
}
|
||||
|
||||
/// Parse author CSS for the live CSS viewport. Media queries must use the
|
||||
/// same dimensions as layout and page JavaScript; filtering them against a
|
||||
/// fixed desktop width made responsive frameworks build one DOM while the
|
||||
/// renderer applied another breakpoint.
|
||||
pub fn parse_for_viewport(
|
||||
tree: &DomTree,
|
||||
sources: &[String],
|
||||
viewport: (f32, f32),
|
||||
) -> Self {
|
||||
let mut sheet = Stylesheet {
|
||||
rules: Vec::new(),
|
||||
by_id: HashMap::new(),
|
||||
@@ -56,7 +68,7 @@ impl Stylesheet {
|
||||
};
|
||||
let mut order = 0usize;
|
||||
for src in sources {
|
||||
for (selector, decls) in parse_stylesheet(src) {
|
||||
for (selector, decls) in parse_stylesheet_for_viewport(src, viewport) {
|
||||
let sel_trim = selector.trim();
|
||||
if let Some(base) = strip_pseudo_element(sel_trim, "before") {
|
||||
if let (Some(content), Some(sel)) = (extract_string_content(&decls), tree.compile_rule_selector(base)) {
|
||||
@@ -359,6 +371,13 @@ fn unescape_css_string(s: &str) -> String {
|
||||
/// (`@media`, `@supports`, `@layer`); other at-rules (`@font-face`, `@keyframes`, ...) are
|
||||
/// skipped since they do not contribute layout-relevant declarations here.
|
||||
pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
|
||||
parse_stylesheet_for_viewport(css, (1280.0, 720.0))
|
||||
}
|
||||
|
||||
fn parse_stylesheet_for_viewport(
|
||||
css: &str,
|
||||
viewport: (f32, f32),
|
||||
) -> Vec<(String, String)> {
|
||||
let mut rules = Vec::new();
|
||||
let mut current_selector = String::new();
|
||||
let mut current_decls = String::new();
|
||||
@@ -397,13 +416,13 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
|
||||
let sel = current_selector.trim();
|
||||
let decls = current_decls.trim();
|
||||
if let Some(at) = sel.strip_prefix('@') {
|
||||
flush_at_rule(at, sel, decls, &mut rules);
|
||||
flush_at_rule(at, sel, decls, &mut rules, viewport);
|
||||
} else {
|
||||
// The body may contain nested rules (CSS Nesting, ubiquitous
|
||||
// in Tailwind v4 / modern frameworks: `.a{ &:hover{} .b{} }`).
|
||||
// Flatten them against this selector; denest also handles the
|
||||
// no-nesting case (just emits the rule's own declarations).
|
||||
denest(sel, decls, &mut rules);
|
||||
denest(sel, decls, &mut rules, viewport);
|
||||
}
|
||||
current_selector.clear();
|
||||
current_decls.clear();
|
||||
@@ -427,13 +446,19 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
|
||||
|
||||
/// Handle the at-rules whose bodies contain ordinary rules. For `@media`, apply
|
||||
/// the inner rules only when the query holds for a desktop 1280px viewport.
|
||||
fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, String)>) {
|
||||
fn flush_at_rule(
|
||||
at: &str,
|
||||
sel: &str,
|
||||
inner: &str,
|
||||
rules: &mut Vec<(String, String)>,
|
||||
viewport: (f32, f32),
|
||||
) {
|
||||
if at.starts_with("media") {
|
||||
if media_query_applies(sel) {
|
||||
rules.extend(parse_stylesheet(inner));
|
||||
if media_query_applies_for_viewport(sel, viewport) {
|
||||
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
|
||||
}
|
||||
} else if at.starts_with("supports") {
|
||||
rules.extend(parse_stylesheet(inner));
|
||||
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
|
||||
} else if at.starts_with("property") {
|
||||
// `@property --x { syntax:..; initial-value: V }` declares a custom
|
||||
// property and its default. Register the initial-value as a `:root`
|
||||
@@ -460,7 +485,7 @@ fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, Stri
|
||||
// left whole pages unstyled (white backgrounds, collapsed layout). The
|
||||
// `@layer a, b;` ordering-statement form has no block and is discarded
|
||||
// by parse_stylesheet's top-level `;` handling.
|
||||
rules.extend(parse_stylesheet(inner));
|
||||
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
|
||||
}
|
||||
// Other at-rules (@font-face, @keyframes, @import, ...) carry no
|
||||
// layout-relevant rules for us, so drop them.
|
||||
@@ -474,7 +499,15 @@ fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, Stri
|
||||
/// inside `(feature: value)`, so it's safe to discard them wholesale rather
|
||||
/// than special-case every formatting variant a site might use.
|
||||
pub(crate) fn media_query_applies(query: &str) -> bool {
|
||||
const VIEWPORT_W: f32 = 1280.0;
|
||||
media_query_applies_for_viewport(query, (1280.0, 720.0))
|
||||
}
|
||||
|
||||
pub(crate) fn media_query_applies_for_viewport(
|
||||
query: &str,
|
||||
viewport: (f32, f32),
|
||||
) -> bool {
|
||||
let viewport_w = viewport.0;
|
||||
let viewport_h = viewport.1;
|
||||
if query.contains("print") {
|
||||
return false;
|
||||
}
|
||||
@@ -508,35 +541,72 @@ pub(crate) fn media_query_applies(query: &str) -> bool {
|
||||
// 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 VIEWPORT_W > px {
|
||||
if viewport_w > px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "min-width:") {
|
||||
if VIEWPORT_W < px {
|
||||
if viewport_w < px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "width<=") {
|
||||
if VIEWPORT_W > px {
|
||||
if viewport_w > px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "width>=") {
|
||||
if VIEWPORT_W < px {
|
||||
if viewport_w < px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "width>") {
|
||||
if VIEWPORT_W <= px {
|
||||
if viewport_w <= px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "width<") {
|
||||
if VIEWPORT_W >= px {
|
||||
if viewport_w >= px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(px) = extract_px(&compact, "max-height:") {
|
||||
if viewport_h > px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "min-height:") {
|
||||
if viewport_h < px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "height<=") {
|
||||
if viewport_h > px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "height>=") {
|
||||
if viewport_h < px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "height>") {
|
||||
if viewport_h <= px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "height<") {
|
||||
if viewport_h >= px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if compact.contains("orientation:portrait") && viewport_w > viewport_h {
|
||||
return false;
|
||||
}
|
||||
if compact.contains("orientation:landscape") && viewport_h > viewport_w {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -573,7 +643,12 @@ fn combine_selectors(parent: &str, child: &str) -> String {
|
||||
/// selector), emit `(sel, own-declarations)`, and recurse into each nested rule
|
||||
/// with the combined selector. Without this, Tailwind v4 / modern-framework CSS
|
||||
/// (which nests almost everything) loses the nested utility rules entirely.
|
||||
fn denest(sel: &str, body: &str, rules: &mut Vec<(String, String)>) {
|
||||
fn denest(
|
||||
sel: &str,
|
||||
body: &str,
|
||||
rules: &mut Vec<(String, String)>,
|
||||
viewport: (f32, f32),
|
||||
) {
|
||||
let chars: Vec<char> = body.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut i = 0;
|
||||
@@ -645,15 +720,15 @@ fn denest(sel: &str, body: &str, rules: &mut Vec<(String, String)>) {
|
||||
if let Some(at) = pre.strip_prefix('@') {
|
||||
// A nested at-rule keeps the enclosing selector for its body.
|
||||
if at.starts_with("media") {
|
||||
if media_query_applies(pre) {
|
||||
denest(sel, &inner, rules);
|
||||
if media_query_applies_for_viewport(pre, viewport) {
|
||||
denest(sel, &inner, rules, viewport);
|
||||
}
|
||||
} else if at.starts_with("supports") || at.starts_with("layer") {
|
||||
denest(sel, &inner, rules);
|
||||
denest(sel, &inner, rules, viewport);
|
||||
}
|
||||
} else if !pre.is_empty() {
|
||||
let full = combine_selectors(sel, pre);
|
||||
denest(&full, &inner, rules);
|
||||
denest(&full, &inner, rules, viewport);
|
||||
}
|
||||
i = j;
|
||||
seg = i;
|
||||
@@ -785,3 +860,54 @@ fn eval_px_sum(expr: &str) -> f32 {
|
||||
flush(&mut num, sign, &mut total);
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn media_rules_use_the_live_width_and_height() {
|
||||
let css = r#"
|
||||
.base { color: black; }
|
||||
@media (max-width: 950px) { .narrow { color: green; } }
|
||||
@media (min-height: 900px) { .tall { color: blue; } }
|
||||
@media (orientation: portrait) { .portrait { color: red; } }
|
||||
"#;
|
||||
let selectors = |viewport| {
|
||||
parse_stylesheet_for_viewport(css, viewport)
|
||||
.into_iter()
|
||||
.map(|(selector, _)| selector)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let narrow_tall = selectors((900.0, 1000.0));
|
||||
assert!(narrow_tall.iter().any(|s| s == ".base"));
|
||||
assert!(narrow_tall.iter().any(|s| s == ".narrow"));
|
||||
assert!(narrow_tall.iter().any(|s| s == ".tall"));
|
||||
assert!(narrow_tall.iter().any(|s| s == ".portrait"));
|
||||
|
||||
let wide_short = selectors((1280.0, 720.0));
|
||||
assert!(wide_short.iter().any(|s| s == ".base"));
|
||||
assert!(!wide_short.iter().any(|s| s == ".narrow"));
|
||||
assert!(!wide_short.iter().any(|s| s == ".tall"));
|
||||
assert!(!wide_short.iter().any(|s| s == ".portrait"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_media_rules_use_the_live_viewport() {
|
||||
let css = ".card { display:block; @media (max-width: 950px) { width:100%; } }";
|
||||
let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0));
|
||||
let wide = parse_stylesheet_for_viewport(css, (1280.0, 720.0));
|
||||
assert!(
|
||||
narrow
|
||||
.iter()
|
||||
.any(|(selector, declarations)| selector == ".card"
|
||||
&& declarations.contains("width:100%"))
|
||||
);
|
||||
assert!(
|
||||
!wide
|
||||
.iter()
|
||||
.any(|(_, declarations)| declarations.contains("width:100%"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -385,7 +385,7 @@ pub fn layout_dom_with_images(
|
||||
}
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let sheet = crate::css::Stylesheet::parse(tree, &css_sources);
|
||||
let sheet = crate::css::Stylesheet::parse_for_viewport(tree, &css_sources, viewport);
|
||||
let t_parse = t0.elapsed();
|
||||
|
||||
let t1 = std::time::Instant::now();
|
||||
|
||||
@@ -110,8 +110,13 @@ pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Underline is flagged per glyph through cosmic-text's `metadata` field.
|
||||
/// Per-glyph flags carried through cosmic-text's `metadata` field. Bit zero is
|
||||
/// the underline flag; the remaining bits encode an optional one-based index
|
||||
/// into an [`InlineItem`]'s clip-text fills.
|
||||
const META_UNDERLINE: usize = 1;
|
||||
const META_FILL_SHIFT: usize = 1;
|
||||
|
||||
type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
|
||||
|
||||
/// One inline formatting context: a shaped cosmic-text buffer plus where to
|
||||
/// paint it (filled in after layout).
|
||||
@@ -125,11 +130,10 @@ pub struct InlineItem {
|
||||
origin: (f32, f32),
|
||||
/// Ancestor `overflow: hidden` clip, set by `finalize`.
|
||||
clip: Option<Rect>,
|
||||
/// `-webkit-background-clip: text` fill: when set, the glyphs are painted
|
||||
/// with this background (a linear gradient sampled across the text box, or a
|
||||
/// solid color as a flat two-stop gradient) instead of the transparent text
|
||||
/// color that would make them invisible. See [`clip_text_fill`].
|
||||
clip_fill: Option<(f32, Vec<([u8; 4], Option<f32>)>)>,
|
||||
/// Per-span `-webkit-background-clip: text` fills. Glyph metadata selects
|
||||
/// one entry, allowing an inline accent span to own a gradient without
|
||||
/// recoloring the rest of its heading.
|
||||
clip_fills: Vec<ClipTextFill>,
|
||||
}
|
||||
|
||||
/// Owns the font set and shaping caches for one render pass, plus every
|
||||
@@ -244,11 +248,11 @@ impl TextEngine {
|
||||
return None;
|
||||
}
|
||||
let base = styles.get(&id)?;
|
||||
let (ctx, clip_fill) = base_span_ctx(base);
|
||||
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
|
||||
let ctx = base_span_ctx(base, &mut collector);
|
||||
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
||||
let mut collector = Collector { last_was_space: true };
|
||||
collect_spans(tree, id, styles, ctx, &mut spans, &mut collector);
|
||||
self.push_shaped_item(base, spans, clip_fill)
|
||||
self.push_shaped_item(base, spans, collector.clip_fills)
|
||||
}
|
||||
|
||||
/// Build an inline formatting context from a *run* of consecutive
|
||||
@@ -275,13 +279,13 @@ impl TextEngine {
|
||||
return None;
|
||||
}
|
||||
let base = styles.get(&parent)?;
|
||||
let (ctx, clip_fill) = base_span_ctx(base);
|
||||
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
|
||||
let ctx = base_span_ctx(base, &mut collector);
|
||||
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
||||
let mut collector = Collector { last_was_space: true };
|
||||
for &cid in run {
|
||||
collect_node_spans(tree, cid, styles, ctx, &mut spans, &mut collector);
|
||||
}
|
||||
self.push_shaped_item(base, spans, clip_fill)
|
||||
self.push_shaped_item(base, spans, collector.clip_fills)
|
||||
}
|
||||
|
||||
/// Shared tail of [`try_build`] / [`try_build_run`]: shape the collected
|
||||
@@ -291,7 +295,7 @@ impl TextEngine {
|
||||
&mut self,
|
||||
base: &LayoutStyle,
|
||||
mut spans: Vec<(String, SpanAttrs)>,
|
||||
clip_fill: Option<(f32, Vec<([u8; 4], Option<f32>)>)>,
|
||||
clip_fills: Vec<ClipTextFill>,
|
||||
) -> Option<usize> {
|
||||
// Trim a single trailing space so it does not widen the last line.
|
||||
if let Some((text, _)) = spans.last_mut() {
|
||||
@@ -363,7 +367,7 @@ impl TextEngine {
|
||||
forced_min_height,
|
||||
origin: (0.0, 0.0),
|
||||
clip: None,
|
||||
clip_fill,
|
||||
clip_fills,
|
||||
});
|
||||
Some(idx)
|
||||
}
|
||||
@@ -442,6 +446,7 @@ struct SpanAttrs {
|
||||
underline: bool,
|
||||
color: [u8; 4],
|
||||
family: &'static str,
|
||||
clip_fill: Option<usize>,
|
||||
}
|
||||
|
||||
impl SpanAttrs {
|
||||
@@ -449,10 +454,13 @@ impl SpanAttrs {
|
||||
let mut a = Attrs::new().family(Family::Name(self.family));
|
||||
a = a.weight(if self.bold { Weight::BOLD } else { Weight::NORMAL });
|
||||
a = a.style(if self.italic { Style::Italic } else { Style::Normal });
|
||||
a = a.color(Color::rgba(self.color[0], self.color[1], self.color[2], self.color[3]));
|
||||
// Underline has no native cosmic-text attribute; carry it through the
|
||||
// per-glyph metadata so paint can stroke it (see `paint_item`).
|
||||
a = a.metadata(if self.underline { META_UNDERLINE } else { 0 });
|
||||
// Clip-text glyphs must be shaped with an opaque fill so their coverage
|
||||
// reaches paint; the real gradient is selected through metadata.
|
||||
let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color };
|
||||
a = a.color(Color::rgba(color[0], color[1], color[2], color[3]));
|
||||
// Underline and the optional fill index share the per-glyph metadata.
|
||||
let fill = self.clip_fill.map_or(0, |index| (index + 1) << META_FILL_SHIFT);
|
||||
a = a.metadata(fill | usize::from(self.underline));
|
||||
a
|
||||
}
|
||||
}
|
||||
@@ -466,10 +474,12 @@ struct SpanCtx {
|
||||
underline: bool,
|
||||
transform: TextTransform,
|
||||
family: &'static str,
|
||||
clip_fill: Option<usize>,
|
||||
}
|
||||
|
||||
struct Collector {
|
||||
last_was_space: bool,
|
||||
clip_fills: Vec<ClipTextFill>,
|
||||
}
|
||||
|
||||
/// DFS the inline subtree, appending whitespace-collapsed text runs. Adjacent
|
||||
@@ -485,22 +495,21 @@ struct Collector {
|
||||
/// shape the glyphs in opaque white so their coverage renders, then recolor
|
||||
/// them from the background at paint time; otherwise transparent text stays
|
||||
/// transparent (and invisible), unchanged.
|
||||
fn base_span_ctx(base: &LayoutStyle) -> (SpanCtx, Option<(f32, Vec<([u8; 4], Option<f32>)>)>) {
|
||||
let clip_fill = clip_text_fill(base);
|
||||
let default_color = if clip_fill.is_some() {
|
||||
[255, 255, 255, 255]
|
||||
} else {
|
||||
base.color.unwrap_or([0, 0, 0, 255])
|
||||
};
|
||||
let ctx = SpanCtx {
|
||||
color: default_color,
|
||||
fn base_span_ctx(base: &LayoutStyle, collector: &mut Collector) -> SpanCtx {
|
||||
let clip_fill = clip_text_fill(base).map(|fill| {
|
||||
let index = collector.clip_fills.len();
|
||||
collector.clip_fills.push(fill);
|
||||
index
|
||||
});
|
||||
SpanCtx {
|
||||
color: base.color.unwrap_or([0, 0, 0, 255]),
|
||||
bold: base.font_weight.as_deref() == Some("bold"),
|
||||
italic: base.font_style_italic.unwrap_or(false),
|
||||
underline: base.underline.unwrap_or(false),
|
||||
transform: base.text_transform.unwrap_or(TextTransform::None),
|
||||
family: resolve_font_family(base.font_family.as_deref()),
|
||||
};
|
||||
(ctx, clip_fill)
|
||||
clip_fill,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_spans(
|
||||
@@ -531,7 +540,14 @@ fn collect_node_spans(
|
||||
let Some(node) = tree.get_node(cid) else { return };
|
||||
match &node.data {
|
||||
obscura_dom::tree::NodeData::Text { contents } => {
|
||||
let attrs = SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family };
|
||||
let attrs = SpanAttrs {
|
||||
bold: ctx.bold,
|
||||
italic: ctx.italic,
|
||||
underline: ctx.underline,
|
||||
color: ctx.color,
|
||||
family: ctx.family,
|
||||
clip_fill: ctx.clip_fill,
|
||||
};
|
||||
push_text(contents, ctx.transform, &attrs, out, c);
|
||||
}
|
||||
_ => {
|
||||
@@ -541,12 +557,31 @@ fn collect_node_spans(
|
||||
return;
|
||||
}
|
||||
if elem.local.as_ref() == "br" {
|
||||
out.push(("\n".to_string(), SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family }));
|
||||
out.push(("\n".to_string(), SpanAttrs {
|
||||
bold: ctx.bold,
|
||||
italic: ctx.italic,
|
||||
underline: ctx.underline,
|
||||
color: ctx.color,
|
||||
family: ctx.family,
|
||||
clip_fill: ctx.clip_fill,
|
||||
}));
|
||||
c.last_was_space = true;
|
||||
return;
|
||||
}
|
||||
let own_clip_fill = style.and_then(clip_text_fill).map(|fill| {
|
||||
let index = c.clip_fills.len();
|
||||
c.clip_fills.push(fill);
|
||||
index
|
||||
});
|
||||
let color = style.and_then(|s| s.color).unwrap_or(ctx.color);
|
||||
// A descendant with its own clip-text background replaces the
|
||||
// inherited fill. Transparent descendants otherwise continue an
|
||||
// ancestor's fill; an opaque text color paints normally.
|
||||
let clip_fill = own_clip_fill.or_else(|| {
|
||||
if color[3] == 0 { ctx.clip_fill } else { None }
|
||||
});
|
||||
let child = SpanCtx {
|
||||
color: style.and_then(|s| s.color).unwrap_or(ctx.color),
|
||||
color,
|
||||
bold: ctx.bold || style.map(|s| s.font_weight.as_deref() == Some("bold")).unwrap_or(false),
|
||||
italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
|
||||
// Underline propagates in: an ancestor's underline covers
|
||||
@@ -557,6 +592,7 @@ fn collect_node_spans(
|
||||
.and_then(|s| s.font_family.as_deref())
|
||||
.map(|f| resolve_font_family(Some(f)))
|
||||
.unwrap_or(ctx.family),
|
||||
clip_fill,
|
||||
};
|
||||
collect_spans(tree, cid, styles, child, out, c);
|
||||
}
|
||||
@@ -807,11 +843,32 @@ impl TextEngine {
|
||||
// consecutive underlined glyphs on a line into one stroke below the
|
||||
// baseline. Done first so the draw() mutable borrow does not overlap.
|
||||
let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color
|
||||
let mut fill_bounds: Vec<Option<(f32, f32, f32, f32)>> =
|
||||
vec![None; item.clip_fills.len()];
|
||||
for run in item.buffer.layout_runs() {
|
||||
let base_y = run.line_y;
|
||||
let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color
|
||||
for g in run.glyphs {
|
||||
let underlined = g.metadata == META_UNDERLINE as usize;
|
||||
let underlined = g.metadata & META_UNDERLINE != 0;
|
||||
if let Some(fill_index) = (g.metadata >> META_FILL_SHIFT).checked_sub(1) {
|
||||
if let Some(bounds) = fill_bounds.get_mut(fill_index) {
|
||||
let glyph_bounds = (
|
||||
g.x,
|
||||
run.line_top,
|
||||
g.x + g.w,
|
||||
run.line_top + run.line_height,
|
||||
);
|
||||
*bounds = Some(match *bounds {
|
||||
Some((x0, y0, x1, y1)) => (
|
||||
x0.min(glyph_bounds.0),
|
||||
y0.min(glyph_bounds.1),
|
||||
x1.max(glyph_bounds.2),
|
||||
y1.max(glyph_bounds.3),
|
||||
),
|
||||
None => glyph_bounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
let col = g.color_opt.map(|c| [c.r(), c.g(), c.b(), c.a()]).unwrap_or([0, 0, 0, 255]);
|
||||
if underlined {
|
||||
match &mut seg {
|
||||
@@ -838,40 +895,52 @@ impl TextEngine {
|
||||
// Fallback color if a glyph carries none (shouldn't happen: every span
|
||||
// sets one), black.
|
||||
let default = Color::rgba(0, 0, 0, 255);
|
||||
// `-webkit-background-clip: text`: recolor each glyph pixel from the
|
||||
// background gradient sampled across the shaped text box, keeping the
|
||||
// glyph's coverage as alpha. The glyphs were shaped opaque (see
|
||||
// `try_build`) so this coverage exists; without a clip fill the per-span
|
||||
// colors pass through unchanged.
|
||||
let clip_fill = item.clip_fill.clone();
|
||||
let fill_extent = clip_fill.as_ref().map(|_| {
|
||||
let (width, height) = buffer_size(&item.buffer);
|
||||
(width, height.max(item.forced_min_height))
|
||||
});
|
||||
// `-webkit-background-clip: text`: rasterize each glyph while its
|
||||
// metadata is still available, then sample that span's background
|
||||
// gradient across the span's own shaped bounds. Buffer::draw omits
|
||||
// metadata from its callback, so using it here would force one fill
|
||||
// over the entire heading and lose inline accent gradients.
|
||||
let clip_fills = item.clip_fills.clone();
|
||||
let pixels = pixmap.pixels_mut();
|
||||
item.buffer.draw(font_system, swash, default, |gx, gy, gw, gh, color| {
|
||||
let a = color.a() as u32;
|
||||
if a == 0 {
|
||||
return;
|
||||
}
|
||||
let (r, g, b) = match (&clip_fill, fill_extent) {
|
||||
(Some(fill), Some((tw, th))) => {
|
||||
let c = sample_gradient(fill, gx as f32 + gw as f32 / 2.0, gy as f32 + gh as f32 / 2.0, tw, th);
|
||||
(c[0], c[1], c[2])
|
||||
}
|
||||
_ => (color.r(), color.g(), color.b()),
|
||||
};
|
||||
for dy in 0..gh as i32 {
|
||||
for dx in 0..gw as i32 {
|
||||
let px = ox as i32 + gx + dx;
|
||||
let py = oy as i32 + gy + dy;
|
||||
for run in item.buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
let physical = glyph.physical((0.0, 0.0), 1.0);
|
||||
let glyph_color = glyph.color_opt.unwrap_or(default);
|
||||
let fill_index = (glyph.metadata >> META_FILL_SHIFT).checked_sub(1);
|
||||
swash.with_pixels(
|
||||
font_system,
|
||||
physical.cache_key,
|
||||
glyph_color,
|
||||
|x, y, color| {
|
||||
let a = color.a() as u32;
|
||||
if a == 0 {
|
||||
return;
|
||||
}
|
||||
let gx = physical.x + x;
|
||||
let gy = run.line_y as i32 + physical.y + y;
|
||||
let (r, g, b) = fill_index
|
||||
.and_then(|index| {
|
||||
let fill = clip_fills.get(index)?;
|
||||
let (x0, y0, x1, y1) = fill_bounds.get(index).copied().flatten()?;
|
||||
let sampled = sample_gradient(
|
||||
fill,
|
||||
gx as f32 + 0.5 - x0,
|
||||
gy as f32 + 0.5 - y0,
|
||||
x1 - x0,
|
||||
y1 - y0,
|
||||
);
|
||||
Some((sampled[0], sampled[1], sampled[2]))
|
||||
})
|
||||
.unwrap_or_else(|| (color.r(), color.g(), color.b()));
|
||||
let px = ox as i32 + gx;
|
||||
let py = oy as i32 + gy;
|
||||
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
|
||||
if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if px < 0 || px >= pw || py < 0 || py >= ph {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
let idx = (py * pw + px) as usize;
|
||||
let dst = pixels[idx];
|
||||
@@ -882,16 +951,17 @@ impl TextEngine {
|
||||
let inv = 255 - sa;
|
||||
let out_a = sa + (dst.alpha() as u32 * inv / 255);
|
||||
if out_a == 0 {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
let out_r = sr + (dst.red() as u32 * inv / 255);
|
||||
let out_g = sg + (dst.green() as u32 * inv / 255);
|
||||
let out_b = sb + (dst.blue() as u32 * inv / 255);
|
||||
pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(out_r as u8, out_g as u8, out_b as u8, out_a as u8)
|
||||
.unwrap_or(dst);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Stroke the underline segments (opaque; text is already drawn above).
|
||||
for (x0, x1, y, thick, col) in underlines {
|
||||
|
||||
@@ -1960,6 +1960,38 @@ mod tests {
|
||||
assert!(found_green, "expected green text to be painted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_vendor_gradient_on_inline_text_span() {
|
||||
// Vue and many other framework sites put the gradient on an inline
|
||||
// accent span, not on the whole heading. The surrounding text must
|
||||
// keep its normal color while this span samples both gradient ends.
|
||||
let tree = parse_html(
|
||||
r#"<html><head><style>
|
||||
h1 { color:#17233c; font-size:50px; margin:0 }
|
||||
html:not(.dark) .accent[data-v-x] {
|
||||
-webkit-text-fill-color:transparent;
|
||||
background:-webkit-linear-gradient(315deg,#42d392 25%,#647eff);
|
||||
-webkit-background-clip:text;
|
||||
background-clip:text
|
||||
}
|
||||
</style></head><body style="margin:0">
|
||||
<h1>The <span class="accent" data-v-x>Progressive</span></h1>
|
||||
</body></html>"#,
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (500.0, 100.0), None).expect("pixmap");
|
||||
let mut green = false;
|
||||
let mut blue = false;
|
||||
let mut normal = false;
|
||||
for pixel in pixmap.pixels() {
|
||||
let (r, g, b) = (pixel.red(), pixel.green(), pixel.blue());
|
||||
green |= g > r.saturating_add(20) && g > b.saturating_add(10);
|
||||
blue |= b > r.saturating_add(20) && b > g.saturating_add(5);
|
||||
normal |= b > g.saturating_add(10) && r < 80 && g < 100;
|
||||
}
|
||||
assert!(normal, "surrounding heading text should retain its normal color");
|
||||
assert!(green && blue, "inline accent should contain both gradient colors");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_inline_svg_subtree() {
|
||||
// A sprite-style svg: a <use> that references a <symbol> in the same
|
||||
|
||||
@@ -448,7 +448,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
||||
// content-box) is an ordinary background, so clear the flag.
|
||||
style.background_clip_text = value.trim().eq_ignore_ascii_case("text");
|
||||
}
|
||||
"color" => style.color = parse_color(value),
|
||||
// Blink/WebKit gradient text commonly makes the glyph fill
|
||||
// transparent through this inherited property while clipping a
|
||||
// background gradient to the text. We model one effective text color,
|
||||
// so the vendor fill color is the paint-time color winner.
|
||||
"color" | "-webkit-text-fill-color" => style.color = parse_color(value),
|
||||
"border-color" => style.border_color = parse_color(value),
|
||||
"font-size" => {
|
||||
// Absolute lengths resolve now; font/viewport-relative ones defer
|
||||
@@ -2508,6 +2512,13 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option<f32>)
|
||||
let v = value.trim();
|
||||
let lower = v.to_ascii_lowercase();
|
||||
let start = lower.find("linear-gradient(")?;
|
||||
// The original prefixed WebKit gradient syntax predates the standardized
|
||||
// angle system: 0deg points right and positive angles turn
|
||||
// counter-clockwise. Blink still accepts that syntax for compatibility.
|
||||
// Convert it to our standard CSS angle (0deg up, clockwise) before paint.
|
||||
let prefix = &lower[..start];
|
||||
let legacy_webkit_angle =
|
||||
prefix.ends_with("-webkit-") || prefix.ends_with("-webkit-repeating-");
|
||||
let open = start + "linear-gradient(".len();
|
||||
// Match the closing paren for this function.
|
||||
let bytes = v.as_bytes();
|
||||
@@ -2546,7 +2557,11 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option<f32>)
|
||||
let mut stop_start = 0;
|
||||
if first.ends_with("deg") {
|
||||
if let Ok(a) = first.trim_end_matches("deg").trim().parse::<f32>() {
|
||||
angle = a;
|
||||
angle = if legacy_webkit_angle {
|
||||
(90.0 - a).rem_euclid(360.0)
|
||||
} else {
|
||||
a.rem_euclid(360.0)
|
||||
};
|
||||
}
|
||||
stop_start = 1;
|
||||
} else if first.starts_with("to ") {
|
||||
@@ -3123,6 +3138,25 @@ mod tests {
|
||||
fn background_clip_text_flag() {
|
||||
let s = compute_style("h1", Some("color: transparent; -webkit-background-clip: text"));
|
||||
assert!(s.background_clip_text);
|
||||
let vendor_fill = compute_style(
|
||||
"h1",
|
||||
Some(
|
||||
"color: black; -webkit-text-fill-color: transparent;\
|
||||
background: linear-gradient(90deg, red, blue);\
|
||||
-webkit-background-clip: text",
|
||||
),
|
||||
);
|
||||
assert_eq!(vendor_fill.color, Some([0, 0, 0, 0]));
|
||||
assert!(vendor_fill.background_clip_text);
|
||||
assert!(vendor_fill.background_gradient.is_some());
|
||||
let legacy_angle = compute_style(
|
||||
"span",
|
||||
Some("background:-webkit-linear-gradient(315deg,#42d392 25%,#647eff)"),
|
||||
)
|
||||
.background_gradient
|
||||
.expect("prefixed gradient")
|
||||
.0;
|
||||
assert_eq!(legacy_angle, 135.0);
|
||||
let n = compute_style("h1", Some("background-clip: border-box"));
|
||||
assert!(!n.background_clip_text);
|
||||
let l = compute_style("h1", Some("background-clip: text"));
|
||||
|
||||
Reference in new issue
Block a user