render: percentage padding, box-shadow, external-sprite svg, gradient text
percentage padding/margin: top/bottom percentages resolve against the containing-block width (the responsive aspect-ratio-box trick, e.g. padding-top:56.25%), resolved to px in the top-down layout pass so card media boxes reserve space instead of collapsing. box-shadow: parse and paint an outset shadow (rounded, offset, spread, soft-edge approximation) so cards/buttons/menus have depth. external-sprite svg: resolve <use href="url#id"> to an external sprite file, inject the referenced symbol so inline-svg icon systems rasterize. background-clip:text: paint transparent-with-gradient text (the common gradient hero/button label) instead of leaving it invisible.
This commit is contained in:
@@ -315,6 +315,12 @@ pub fn layout_dom_with_images(
|
||||
line_height: crate::LineHeight,
|
||||
text_transform: crate::TextTransform,
|
||||
italic: bool,
|
||||
/// Containing-block width in px for the current element, carried
|
||||
/// down so percentage padding/margin (which resolve against the
|
||||
/// containing block WIDTH, all sides) can be turned into px before
|
||||
/// taffy layout. Not a CSS-inherited property; it is recomputed to
|
||||
/// the element's own content width for its children.
|
||||
cb_width: f32,
|
||||
}
|
||||
impl Default for Inherited {
|
||||
fn default() -> Self {
|
||||
@@ -329,6 +335,7 @@ pub fn layout_dom_with_images(
|
||||
line_height: crate::LineHeight::Normal,
|
||||
text_transform: crate::TextTransform::None,
|
||||
italic: false,
|
||||
cb_width: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,8 +358,15 @@ pub fn layout_dom_with_images(
|
||||
}
|
||||
};
|
||||
|
||||
let mut queue = vec![(root_id, Inherited::default())];
|
||||
// The root element's containing block is the initial containing block,
|
||||
// i.e. the viewport width.
|
||||
let mut root_inh = Inherited::default();
|
||||
root_inh.cb_width = viewport.0;
|
||||
let mut queue = vec![(root_id, root_inh)];
|
||||
while let Some((id, mut inh)) = queue.pop() {
|
||||
// Default the child containing-block width to this element's own
|
||||
// (updated to its content width inside the block below).
|
||||
let mut child_cb_width = inh.cb_width;
|
||||
if let Some(style) = styles.get_mut(&id) {
|
||||
match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color }
|
||||
// Resolve a relative font-size against the PARENT (em/%) or
|
||||
@@ -393,7 +407,51 @@ pub fn layout_dom_with_images(
|
||||
match style.line_height { Some(v) => inh.line_height = v, None => style.line_height = Some(inh.line_height) }
|
||||
match style.text_transform { Some(v) => inh.text_transform = v, None => style.text_transform = Some(inh.text_transform) }
|
||||
match style.font_style_italic { Some(v) => inh.italic = v, None => style.font_style_italic = Some(inh.italic) }
|
||||
|
||||
// Percentage padding/margin resolve against the containing
|
||||
// block WIDTH on every side (per CSS: `padding-top:56.25%` in a
|
||||
// 1000px block is 562.5px, not a fraction of the height). The
|
||||
// f32 Edges cannot carry a percentage, so bake the resolved px
|
||||
// back into `padding`/`margin`, which then feed taffy.
|
||||
let cb_w = inh.cb_width;
|
||||
for i in 0..4 {
|
||||
if let Some(frac) = style.padding_percent[i] {
|
||||
let px = (frac * cb_w).max(0.0);
|
||||
match i {
|
||||
0 => style.padding.top = px,
|
||||
1 => style.padding.right = px,
|
||||
2 => style.padding.bottom = px,
|
||||
_ => style.padding.left = px,
|
||||
}
|
||||
}
|
||||
if let Some(frac) = style.margin_percent[i] {
|
||||
let px = frac * cb_w;
|
||||
match i {
|
||||
0 => style.margin.top = px,
|
||||
1 => style.margin.right = px,
|
||||
2 => style.margin.bottom = px,
|
||||
_ => style.margin.left = px,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Containing-block width handed to this element's children is
|
||||
// its own content-box width. taffy sizes width as border-box
|
||||
// (its default box_sizing), so subtract this element's resolved
|
||||
// padding and border; an auto width fills the containing block.
|
||||
let used_w = match style.width {
|
||||
crate::Dimension::Px(w) => w,
|
||||
crate::Dimension::Percent(p) => p * cb_w,
|
||||
_ => (cb_w - style.margin.left - style.margin.right).max(0.0),
|
||||
};
|
||||
child_cb_width = (used_w
|
||||
- style.padding.left
|
||||
- style.padding.right
|
||||
- style.border.left
|
||||
- style.border.right)
|
||||
.max(0.0);
|
||||
}
|
||||
inh.cb_width = child_cb_width;
|
||||
for cid in tree.children(id).into_iter().rev() {
|
||||
queue.push((cid, inh.clone()));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ 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>)>)>,
|
||||
}
|
||||
|
||||
/// Owns the font set and shaping caches for one render pass, plus every
|
||||
@@ -90,7 +95,17 @@ impl TextEngine {
|
||||
}
|
||||
let base = styles.get(&id)?;
|
||||
let base_size = base.font_size.unwrap_or(16.0);
|
||||
let default_color = base.color.unwrap_or([0, 0, 0, 255]);
|
||||
// `-webkit-background-clip: text` on a transparent-colored element paints
|
||||
// its background *through* the glyphs (gradient/solid text). When active,
|
||||
// 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.
|
||||
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 root_transform = base.text_transform.unwrap_or(TextTransform::None);
|
||||
let root_underline = base.underline.unwrap_or(false);
|
||||
@@ -150,7 +165,7 @@ impl TextEngine {
|
||||
}
|
||||
|
||||
let idx = self.items.len();
|
||||
self.items.push(InlineItem { buffer, origin: (0.0, 0.0), clip: None });
|
||||
self.items.push(InlineItem { buffer, origin: (0.0, 0.0), clip: None, clip_fill });
|
||||
Some(idx)
|
||||
}
|
||||
|
||||
@@ -413,6 +428,67 @@ pub fn content_width(rect: &Rect, style: &LayoutStyle) -> f32 {
|
||||
(rect.width - style.border.left - style.border.right - style.padding.left - style.padding.right).max(0.0)
|
||||
}
|
||||
|
||||
/// The background to paint through the glyphs for `-webkit-background-clip: text`
|
||||
/// when the element's own text color is transparent (the common gradient-text
|
||||
/// technique on hero headings and buttons). Returns the background gradient as
|
||||
/// is, or a solid background color as a flat two-stop gradient. `None` when the
|
||||
/// element is not a transparent-text clip-to-text box, so ordinary transparent
|
||||
/// text still renders invisibly.
|
||||
fn clip_text_fill(style: &LayoutStyle) -> Option<(f32, Vec<([u8; 4], Option<f32>)>)> {
|
||||
if !style.background_clip_text {
|
||||
return None;
|
||||
}
|
||||
// Only when the text itself is transparent: an opaque color paints normally
|
||||
// and the clip is a no-op we would otherwise recolor incorrectly.
|
||||
if style.color.map(|c| c[3] != 0).unwrap_or(true) {
|
||||
return None;
|
||||
}
|
||||
if let Some(g) = &style.background_gradient {
|
||||
if g.1.len() >= 2 {
|
||||
return Some(g.clone());
|
||||
}
|
||||
}
|
||||
let bg = style.background_color.filter(|c| c[3] != 0)?;
|
||||
Some((180.0, vec![(bg, Some(0.0)), (bg, Some(1.0))]))
|
||||
}
|
||||
|
||||
/// Sample a CSS linear gradient at point `(x, y)` inside a `w` x `h` text box,
|
||||
/// returning an rgba color. `angle` is CSS degrees clockwise from 12 o'clock
|
||||
/// (0 = to top, 90 = to right, 180 = to bottom), matching `parse_linear_gradient`
|
||||
/// and `paint::paint_linear_gradient`. Positionless stops are spread evenly.
|
||||
fn sample_gradient(fill: &(f32, Vec<([u8; 4], Option<f32>)>), x: f32, y: f32, w: f32, h: f32) -> [u8; 4] {
|
||||
let (angle, stops) = fill;
|
||||
match stops.len() {
|
||||
0 => return [0, 0, 0, 255],
|
||||
1 => return stops[0].0,
|
||||
_ => {}
|
||||
}
|
||||
let rad = angle.to_radians();
|
||||
let (dx, dy) = (rad.sin(), -rad.cos());
|
||||
let (w, h) = (w.max(1.0), h.max(1.0));
|
||||
// Full extent of the box along the gradient direction (the CSS gradient-line
|
||||
// length), so the endpoints land at the box's projected corners.
|
||||
let len = (w * dx).abs() + (h * dy).abs();
|
||||
let t = if len <= 0.0 {
|
||||
0.5
|
||||
} else {
|
||||
(((x - w / 2.0) * dx + (y - h / 2.0) * dy) / len + 0.5).clamp(0.0, 1.0)
|
||||
};
|
||||
let n = stops.len();
|
||||
let pos = |i: usize| stops[i].1.unwrap_or(i as f32 / (n as f32 - 1.0)).clamp(0.0, 1.0);
|
||||
// Walk to the pair of stops surrounding t, then interpolate between them.
|
||||
let mut lo = 0usize;
|
||||
while lo + 1 < n && pos(lo + 1) < t {
|
||||
lo += 1;
|
||||
}
|
||||
let hi = (lo + 1).min(n - 1);
|
||||
let (p0, p1) = (pos(lo), pos(hi));
|
||||
let f = if (p1 - p0).abs() < 1e-6 { 0.0 } else { ((t - p0) / (p1 - p0)).clamp(0.0, 1.0) };
|
||||
let (c0, c1) = (stops[lo].0, stops[hi].0);
|
||||
let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * f).round().clamp(0.0, 255.0) as u8;
|
||||
[lerp(c0[0], c1[0]), lerp(c0[1], c1[1]), lerp(c0[2], c1[2]), lerp(c0[3], c1[3])]
|
||||
}
|
||||
|
||||
impl TextEngine {
|
||||
/// Rasterize inline context `idx` into `pixmap`, honoring its clip. Uses
|
||||
/// cosmic-text's swash-backed rasterizer (anti-aliased, per-glyph color
|
||||
@@ -466,13 +542,26 @@ 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(|_| buffer_size(&item.buffer));
|
||||
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) = (color.r(), color.g(), color.b());
|
||||
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;
|
||||
@@ -534,3 +623,51 @@ impl TextEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
|
||||
#[test]
|
||||
fn clip_fill_only_for_transparent_clip_text() {
|
||||
let mut s = LayoutStyle::default();
|
||||
// Gradient + clip-to-text + transparent color: fills through the glyphs.
|
||||
s.background_clip_text = true;
|
||||
s.color = Some([0, 0, 0, 0]);
|
||||
s.background_gradient = Some((90.0, vec![(RED, None), (BLUE, None)]));
|
||||
assert!(clip_text_fill(&s).is_some());
|
||||
|
||||
// Same, but opaque text: paints normally, no clip fill.
|
||||
s.color = Some([10, 20, 30, 255]);
|
||||
assert!(clip_text_fill(&s).is_none());
|
||||
|
||||
// Clip-to-text off: ordinary transparent text stays invisible.
|
||||
s.color = Some([0, 0, 0, 0]);
|
||||
s.background_clip_text = false;
|
||||
assert!(clip_text_fill(&s).is_none());
|
||||
|
||||
// Solid background color becomes a flat two-stop gradient.
|
||||
s.background_clip_text = true;
|
||||
s.background_gradient = None;
|
||||
s.background_color = Some([12, 34, 56, 255]);
|
||||
let fill = clip_text_fill(&s).expect("solid bg clip fill");
|
||||
assert_eq!(fill.1.len(), 2);
|
||||
assert_eq!(fill.1[0].0, [12, 34, 56, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_gradient_tints_left_to_right() {
|
||||
// 90deg (to right): left edge is the first stop, right edge the last.
|
||||
let fill = (90.0f32, vec![(RED, None), (BLUE, None)]);
|
||||
let left = sample_gradient(&fill, 0.0, 5.0, 100.0, 10.0);
|
||||
let right = sample_gradient(&fill, 100.0, 5.0, 100.0, 10.0);
|
||||
assert!(left[0] > left[2], "left end should be reddish: {left:?}");
|
||||
assert!(right[2] > right[0], "right end should be bluish: {right:?}");
|
||||
// A single-color list samples to that color everywhere.
|
||||
let flat = (0.0f32, vec![([7, 8, 9, 255], None)]);
|
||||
assert_eq!(sample_gradient(&flat, 3.0, 3.0, 20.0, 20.0), [7, 8, 9, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,21 @@ pub struct LayoutStyle {
|
||||
/// auto` / `margin-inline: auto` centering needs a real Auto margin, which
|
||||
/// the f32 `margin` cannot express; this flag drives it at taffy mapping.
|
||||
pub margin_auto: [bool; 4],
|
||||
/// Percentage margin per side (top, right, bottom, left) as a 0..1 fraction,
|
||||
/// `None` when the side is a fixed length. Like padding, every side resolves
|
||||
/// against the containing block's WIDTH; the f32 `margin` cannot carry a
|
||||
/// percentage, so this is resolved to px during `dom::layout_dom`'s top-down
|
||||
/// pass once the containing-block width is known.
|
||||
pub margin_percent: [Option<f32>; 4],
|
||||
pub padding: Edges,
|
||||
/// Percentage padding per side (top, right, bottom, left) as a 0..1
|
||||
/// fraction, `None` when the side is a fixed length. All four sides resolve
|
||||
/// against the containing block's WIDTH (per CSS, including top/bottom): this
|
||||
/// is the responsive aspect-ratio-box trick (`padding-top:56.25%` reserves a
|
||||
/// 16:9 area). The f32 `padding` cannot carry a percentage, so it is
|
||||
/// resolved to px in `dom::layout_dom`'s top-down pass and written back into
|
||||
/// `padding`, which then feeds taffy as a fixed length.
|
||||
pub padding_percent: [Option<f32>; 4],
|
||||
pub border: Edges,
|
||||
/// `border-radius` (uniform; the first value of the shorthand). Rounds the
|
||||
/// background fill and border. In px after resolution.
|
||||
@@ -203,6 +217,14 @@ pub struct LayoutStyle {
|
||||
/// `background_size`: without a known image size there is no leftover
|
||||
/// box space to position within.
|
||||
pub background_position: (f32, f32),
|
||||
/// `background-clip: text` / `-webkit-background-clip: text`: the background
|
||||
/// paints only through the element's glyphs, not as a filled box. Combined
|
||||
/// with a transparent text color this is the common gradient-text technique
|
||||
/// (hero headings, buttons like astro.build's "Get Started"); without
|
||||
/// honoring it those labels paint invisible. Consumed in the text paint path
|
||||
/// (`inline::TextEngine` fills glyphs from the background; `paint` suppresses
|
||||
/// the box fill so the gradient does not paint as a rectangle).
|
||||
pub background_clip_text: bool,
|
||||
/// `mask-image`/`-webkit-mask-image: url(...)`: the ubiquitous "colored,
|
||||
/// scalable icon" pattern (an SVG shape used as a stencil, tinted by
|
||||
/// `background-color`/`color` instead of carrying its own colors). Without
|
||||
@@ -366,6 +388,11 @@ pub struct LayoutStyle {
|
||||
/// correctly would require scaling every descendant's size and text, outside
|
||||
/// the paint-time translate-offset model used here).
|
||||
pub transform_scale: Option<(f32, f32)>,
|
||||
/// `box-shadow` (first layer only). Painted behind the element's own
|
||||
/// background/border box: cards, buttons, menus, and modals across the
|
||||
/// modern web rely on it for depth, and without it those elements paint
|
||||
/// flat. See [`BoxShadow`] and `paint::paint_box_shadow`.
|
||||
pub box_shadow: Option<BoxShadow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -374,6 +401,21 @@ pub enum Float {
|
||||
Right,
|
||||
}
|
||||
|
||||
/// One `box-shadow` layer. Offsets, blur, and spread are in CSS px; `color` is
|
||||
/// the resolved RGBA (falling back to the element's text color, per CSS
|
||||
/// `currentColor`, when the value omits a color); `inset` distinguishes an
|
||||
/// inner shadow from the default outer (drop) shadow. Only the first layer of a
|
||||
/// comma-separated list is modeled.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BoxShadow {
|
||||
pub offset_x: f32,
|
||||
pub offset_y: f32,
|
||||
pub blur: f32,
|
||||
pub spread: f32,
|
||||
pub color: [u8; 4],
|
||||
pub inset: bool,
|
||||
}
|
||||
|
||||
/// `object-fit` for replaced elements (`<img>`): how the image's intrinsic
|
||||
/// content is scaled into its box when their aspect ratios differ. `Fill` (the
|
||||
/// default) stretches to the whole box; the others preserve the image's aspect
|
||||
|
||||
@@ -39,6 +39,11 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
// box/text loop below and the inline-formatting loop after it (an svg
|
||||
// `<text>` element must not also paint its glyphs on top of the raster).
|
||||
let mut svg_subtree_skip: std::collections::HashSet<obscura_dom::tree::NodeId> = std::collections::HashSet::new();
|
||||
// External sprite symbols, keyed by "url#id", extracted from a fetched
|
||||
// sprite file so a `<use href="url#id">` resolves. One sprite backs many
|
||||
// icons (a whole logo/icon band), so cache the parsed symbol across every
|
||||
// inline svg on the page rather than re-parsing the sprite per icon.
|
||||
let mut sprite_cache: std::collections::HashMap<String, Option<String>> = std::collections::HashMap::new();
|
||||
// Whether any element carries a `transform: translate()`. When none does
|
||||
// (the overwhelmingly common case), every node's accumulated offset is
|
||||
// zero, so skip the per-node ancestor walk entirely and keep the paint
|
||||
@@ -110,6 +115,14 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Outset box-shadow paints behind this element's own background/border.
|
||||
// Geometry comes from the full (translate-adjusted) border box; the
|
||||
// ancestor overflow clip is reapplied inside so the shadow is clipped by
|
||||
// an ancestor exactly as the box itself is.
|
||||
if let Some(shadow) = style.box_shadow {
|
||||
paint_box_shadow(&mut pixmap, &shadow, &rect, style.border_radius, clip);
|
||||
}
|
||||
|
||||
// Box path (rounded if border-radius), reused for gradient/color fill.
|
||||
let r = style.border_radius;
|
||||
let bg_path = || if r > 0.5 {
|
||||
@@ -121,7 +134,9 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
};
|
||||
// A linear-gradient background (heavily used by modern hero sections);
|
||||
// without this it paints white. Takes precedence over a solid color.
|
||||
if style.mask_image.is_none() {
|
||||
// `background-clip: text` clips the background to the glyphs, so it must
|
||||
// not paint as a box here; the text paint path fills the glyphs instead.
|
||||
if style.mask_image.is_none() && !style.background_clip_text {
|
||||
if let Some((angle, stops)) = &style.background_gradient {
|
||||
if let Some(path) = bg_path() {
|
||||
paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops);
|
||||
@@ -252,7 +267,13 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
// is drawn at its full border-box size (undistorted) and clipped to the
|
||||
// overflow-visible region.
|
||||
if name.local.as_ref() == "svg" {
|
||||
let markup = serialize_svg(tree, nid);
|
||||
let mut markup = serialize_svg(tree, nid);
|
||||
// `<use href="url#id">` pointing at an EXTERNAL sprite file resolves
|
||||
// to nothing in resvg (the symbol lives in another document). Fetch
|
||||
// the sprite, splice the referenced `<symbol>` into a local `<defs>`,
|
||||
// and rewrite the href to a same-document `#id`. Same-document
|
||||
// `<use href="#id">` (empty url) is untouched.
|
||||
inject_external_sprites(tree, nid, base_url, &mut markup, &mut image_cache, &mut sprite_cache);
|
||||
if let Some(content) = render_svg(markup.as_bytes(), rect.width as u32, rect.height as u32) {
|
||||
let mask = clip.and_then(|_| rect_clip_mask(pixmap.width(), pixmap.height(), &visible_rect));
|
||||
pixmap.draw_pixmap(
|
||||
@@ -407,6 +428,105 @@ fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Option<tiny_skia
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Paint an outset `box-shadow` layer behind the element's own box. `rect` is
|
||||
/// the element's (translate-adjusted) border box; the shadow is that box offset
|
||||
/// by (offset_x, offset_y), expanded by `spread`, with a `blur`-wide soft edge.
|
||||
/// tiny-skia has no gaussian blur, so the blur is approximated by nested
|
||||
/// rounded rects from a solid core out to the blur radius, each at a fraction of
|
||||
/// the shadow alpha so source-over accumulation ramps the coverage from full at
|
||||
/// the core to near-zero at the outer edge. `inset` shadows are parsed but not
|
||||
/// painted (an inner shadow needs a hole-punched fill this box model does not
|
||||
/// build). `clip`, when set, is the ancestor `overflow: hidden` region and is
|
||||
/// applied as a mask so the shadow is clipped like the element itself.
|
||||
fn paint_box_shadow(
|
||||
pixmap: &mut Pixmap,
|
||||
shadow: &crate::BoxShadow,
|
||||
rect: &crate::Rect,
|
||||
border_radius: f32,
|
||||
clip: Option<crate::Rect>,
|
||||
) {
|
||||
if shadow.inset || shadow.color[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let spread = shadow.spread;
|
||||
let x0 = rect.x + shadow.offset_x - spread;
|
||||
let y0 = rect.y + shadow.offset_y - spread;
|
||||
let w0 = rect.width + 2.0 * spread;
|
||||
let h0 = rect.height + 2.0 * spread;
|
||||
if w0 <= 0.0 || h0 <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let r0 = (border_radius + spread).max(0.0);
|
||||
let blur = shadow.blur.max(0.0);
|
||||
// Ancestor overflow clip: build a mask once and reuse it for every layer.
|
||||
let mask = match clip {
|
||||
Some(c) => {
|
||||
if c.width <= 0.0 || c.height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
box_clip_mask(pixmap.width(), pixmap.height(), &c)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let color = shadow.color;
|
||||
if blur < 0.5 {
|
||||
// No blur: a single crisp, offset (and spread) rounded rect.
|
||||
fill_shadow_rect(pixmap, x0, y0, w0, h0, r0, color, mask.as_ref());
|
||||
return;
|
||||
}
|
||||
let steps: u32 = (blur.ceil() as u32).clamp(2, 24);
|
||||
// Per-layer alpha chosen so `steps` source-over composites reach the target
|
||||
// alpha at the core: 1 - (1 - a)^steps == A => a = 1 - (1 - A)^(1/steps).
|
||||
let a_frac = color[3] as f32 / 255.0;
|
||||
let per = 1.0 - (1.0 - a_frac).powf(1.0 / steps as f32);
|
||||
let layer_alpha = (per * 255.0).round().clamp(1.0, 255.0) as u8;
|
||||
let layer_color = [color[0], color[1], color[2], layer_alpha];
|
||||
for j in 0..steps {
|
||||
// j = 0 is the solid core (expansion 0); j = steps-1 reaches the blur
|
||||
// radius. Larger rects paint first, smaller (more-covered) ones on top.
|
||||
let e = blur * (j as f32) / ((steps - 1) as f32);
|
||||
fill_shadow_rect(pixmap, x0 - e, y0 - e, w0 + 2.0 * e, h0 + 2.0 * e, r0 + e, layer_color, mask.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill one (possibly rounded) shadow rectangle with a flat color, optionally
|
||||
/// masked to an ancestor clip region. A helper for `paint_box_shadow`'s layers.
|
||||
fn fill_shadow_rect(
|
||||
pixmap: &mut Pixmap,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
radius: f32,
|
||||
color: [u8; 4],
|
||||
mask: Option<&tiny_skia::Mask>,
|
||||
) {
|
||||
if w <= 0.0 || h <= 0.0 || color[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let path = if radius > 0.5 {
|
||||
match rounded_rect_path(x, y, w, h, radius) {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
}
|
||||
} else {
|
||||
let r = match Rect::from_xywh(x, y, w, h) {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_rect(r);
|
||||
match pb.finish() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3]));
|
||||
paint.anti_alias = true;
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), mask);
|
||||
}
|
||||
|
||||
/// The marker text for a list item, or `None` when markers are suppressed
|
||||
/// (`list-style: none`). `Decimal` numbers the item by its position among
|
||||
/// sibling list items so `<ol>`s count 1, 2, 3.
|
||||
@@ -436,6 +556,27 @@ pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&st
|
||||
paint_dom(tree, viewport, base_url)?.encode_png().ok()
|
||||
}
|
||||
|
||||
/// A representative visible color for `background-clip: text` text whose own
|
||||
/// color is transparent, used on the word-split paint path (the cosmic-text IFC
|
||||
/// path samples the gradient per glyph in `inline`). Returns the gradient's mid
|
||||
/// stop or the background color so a transparent-colored label still paints;
|
||||
/// `None` when the element is not a transparent-text clip-to-text box.
|
||||
fn clip_text_fill_color(style: &crate::LayoutStyle) -> Option<[u8; 4]> {
|
||||
if !style.background_clip_text {
|
||||
return None;
|
||||
}
|
||||
if style.color.map(|c| c[3] != 0).unwrap_or(true) {
|
||||
return None;
|
||||
}
|
||||
if let Some((_, stops)) = &style.background_gradient {
|
||||
if !stops.is_empty() {
|
||||
let mid = stops[stops.len() / 2].0;
|
||||
return Some([mid[0], mid[1], mid[2], 255]);
|
||||
}
|
||||
}
|
||||
style.background_color.filter(|c| c[3] != 0).map(|c| [c[0], c[1], c[2], 255])
|
||||
}
|
||||
|
||||
/// Paint every word of a text node at its own laid-out position. A text node
|
||||
/// lays out as one taffy leaf per word (see `dom::build_text_words`), each
|
||||
/// wrapping independently, so its content is a list of (box, word) pairs
|
||||
@@ -455,7 +596,7 @@ fn paint_text_node(
|
||||
if style.effectively_invisible {
|
||||
return Some(());
|
||||
}
|
||||
let color = style.color.unwrap_or([0, 0, 0, 255]);
|
||||
let color = clip_text_fill_color(style).unwrap_or_else(|| style.color.unwrap_or([0, 0, 0, 255]));
|
||||
let fsize = style.font_size.unwrap_or(16.0);
|
||||
let is_bold = style.font_weight.as_deref() == Some("bold");
|
||||
// A text node has no transform of its own, but any transformed element
|
||||
@@ -1252,6 +1393,259 @@ fn svg_escape_attr(s: &str, buf: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `<use>` elements in an inline `<svg>` subtree that point at an
|
||||
/// EXTERNAL sprite file (`href`/`xlink:href` = `url#id` with a non-empty url),
|
||||
/// splicing the referenced symbol into the already-serialized `markup` so resvg
|
||||
/// can rasterize it. For each distinct external reference, fetch the sprite
|
||||
/// (once, via the shared image cache and resolving relative urls against
|
||||
/// `base_url`), pull out the element carrying `id="id"`, inject it inside a
|
||||
/// `<defs>` right after the opening `<svg>` tag, and rewrite the `<use>` href to
|
||||
/// a now-local `#id`. Same-document `<use href="#id">` (empty url) is ignored
|
||||
/// and left to resvg's own resolution. A fetch or parse failure is a silent
|
||||
/// no-op: the affected `<use>` simply renders nothing, exactly as before.
|
||||
fn inject_external_sprites(
|
||||
tree: &DomTree,
|
||||
root: obscura_dom::tree::NodeId,
|
||||
base_url: Option<&str>,
|
||||
markup: &mut String,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
sprite_cache: &mut std::collections::HashMap<String, Option<String>>,
|
||||
) {
|
||||
// Distinct external references (full href, url, fragment id), in first-seen
|
||||
// order. Dedupe so one symbol referenced by several `<use>` is fetched and
|
||||
// injected once (the rewrite below still fixes every occurrence).
|
||||
let mut refs: Vec<(String, String, String)> = Vec::new();
|
||||
for nid in tree.descendants(root) {
|
||||
let Some(node) = tree.get_node(nid) else { continue };
|
||||
let Some(el) = node.as_element() else { continue };
|
||||
if el.local.as_ref() != "use" {
|
||||
continue;
|
||||
}
|
||||
// `get_attribute` matches by local name, so a single "href" lookup
|
||||
// already covers both `href` and `xlink:href`; check the prefixed form
|
||||
// too for completeness.
|
||||
let Some(href) = node
|
||||
.get_attribute("href")
|
||||
.or_else(|| node.get_attribute("xlink:href"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(hash) = href.find('#') else { continue };
|
||||
let (url, frag) = (&href[..hash], &href[hash + 1..]);
|
||||
if url.is_empty() || frag.is_empty() {
|
||||
// Same-document `#id` (or a malformed ref): nothing to fetch.
|
||||
continue;
|
||||
}
|
||||
let entry = (href.to_string(), url.to_string(), frag.to_string());
|
||||
if !refs.contains(&entry) {
|
||||
refs.push(entry);
|
||||
}
|
||||
}
|
||||
if refs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut defs = String::new();
|
||||
let mut rewrites: Vec<(String, String)> = Vec::new();
|
||||
for (href, url, frag) in &refs {
|
||||
let key = format!("{url}#{frag}");
|
||||
let symbol = sprite_cache
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
let bytes = fetch_bytes(url, base_url, cache)?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
// Drop `xlink:` prefixes in the fetched fragment (resvg reads a
|
||||
// bare `href`), matching how the local subtree is serialized and
|
||||
// avoiding an undeclared-namespace parse error in the standalone
|
||||
// document.
|
||||
extract_svg_element_by_id(&text, frag).map(|s| s.replace("xlink:href", "href"))
|
||||
})
|
||||
.clone();
|
||||
let Some(symbol) = symbol else { continue };
|
||||
defs.push_str(&symbol);
|
||||
rewrites.push((href.clone(), format!("#{frag}")));
|
||||
}
|
||||
if defs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Splice the fetched symbols into a `<defs>` immediately after the opening
|
||||
// `<svg ...>` tag (the first `>` in the serialized document).
|
||||
if let Some(gt) = markup.find('>') {
|
||||
markup.insert_str(gt + 1, &format!("<defs>{defs}</defs>"));
|
||||
}
|
||||
// Point each external `<use>` at the injected local symbol. The serialized
|
||||
// href is attribute-escaped, so match against the escaped form.
|
||||
for (href, local) in rewrites {
|
||||
let from = format!("href=\"{}\"", svg_escape_attr_str(&href));
|
||||
let to = format!("href=\"{}\"", svg_escape_attr_str(&local));
|
||||
*markup = markup.replace(&from, &to);
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape a string for use as an SVG attribute value (`&`, `<`, `"`), returning
|
||||
/// it as an owned `String` (the buffer-writing `svg_escape_attr` in one call).
|
||||
fn svg_escape_attr_str(s: &str) -> String {
|
||||
let mut buf = String::new();
|
||||
svg_escape_attr(s, &mut buf);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Pull the element carrying `id="id"` (a `<symbol>`, `<g>`, `<path>`, ...) out
|
||||
/// of an external sprite document, returned as a verbatim serialized substring
|
||||
/// (its start tag through the matching end tag, or the self-closing tag alone).
|
||||
/// A lightweight namespace-agnostic XML scan, not a full parse: usvg would
|
||||
/// flatten `<symbol>`/`<use>` structure, and we want to re-inject the element
|
||||
/// unchanged. Returns None when no element has that id.
|
||||
fn extract_svg_element_by_id(sprite: &str, id: &str) -> Option<String> {
|
||||
let mut i = 0usize;
|
||||
while i < sprite.len() {
|
||||
let rest = &sprite[i..];
|
||||
if !rest.starts_with('<') {
|
||||
// Advance to the next tag (skips text/whitespace between elements).
|
||||
i += rest.find('<')?;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<!--") {
|
||||
i += rest.find("-->").map(|p| p + 3)?;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<![CDATA[") {
|
||||
i += rest.find("]]>").map(|p| p + 3)?;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<!") || rest.starts_with("<?") || rest.starts_with("</") {
|
||||
i += rest.find('>').map(|p| p + 1)?;
|
||||
continue;
|
||||
}
|
||||
// A start tag: inner spans between '<' and '>'.
|
||||
let gt = i + rest.find('>')?;
|
||||
let inner = &sprite[i + 1..gt];
|
||||
if tag_attr(inner, "id") == Some(id) {
|
||||
if inner.trim_end().ends_with('/') {
|
||||
return Some(sprite[i..=gt].to_string());
|
||||
}
|
||||
let name = tag_name(inner);
|
||||
let end = element_end(sprite, gt + 1, name)?;
|
||||
return Some(sprite[i..end].to_string());
|
||||
}
|
||||
i = gt + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The tag name from a tag's inner text (the bytes between `<` and `>`),
|
||||
/// dropping any leading `/` of an end tag and stopping at the first whitespace
|
||||
/// or self-close slash.
|
||||
fn tag_name(inner: &str) -> &str {
|
||||
let inner = inner.trim_start().trim_start_matches('/');
|
||||
let end = inner
|
||||
.find(|c: char| c.is_ascii_whitespace() || c == '/')
|
||||
.unwrap_or(inner.len());
|
||||
&inner[..end]
|
||||
}
|
||||
|
||||
/// The value of attribute `want` in a tag's inner text, or None if absent.
|
||||
/// Matches attribute names whole (so `id` does not match `data-id`/`xml:id`)
|
||||
/// and handles single/double quoted and bare values.
|
||||
fn tag_attr<'a>(inner: &'a str, want: &str) -> Option<&'a str> {
|
||||
let b = inner.as_bytes();
|
||||
let mut i = 0usize;
|
||||
// Skip the tag name.
|
||||
while i < b.len() && !b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
while i < b.len() {
|
||||
while i < b.len() && b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= b.len() || b[i] == b'/' {
|
||||
break;
|
||||
}
|
||||
let name_start = i;
|
||||
while i < b.len() && b[i] != b'=' && !b[i].is_ascii_whitespace() && b[i] != b'/' {
|
||||
i += 1;
|
||||
}
|
||||
let name = &inner[name_start..i];
|
||||
while i < b.len() && b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i < b.len() && b[i] == b'=' {
|
||||
i += 1;
|
||||
while i < b.len() && b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
let value = if i < b.len() && (b[i] == b'"' || b[i] == b'\'') {
|
||||
let quote = b[i];
|
||||
i += 1;
|
||||
let vstart = i;
|
||||
while i < b.len() && b[i] != quote {
|
||||
i += 1;
|
||||
}
|
||||
let v = &inner[vstart..i.min(b.len())];
|
||||
if i < b.len() {
|
||||
i += 1;
|
||||
}
|
||||
v
|
||||
} else {
|
||||
let vstart = i;
|
||||
while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'/' {
|
||||
i += 1;
|
||||
}
|
||||
&inner[vstart..i]
|
||||
};
|
||||
if name == want {
|
||||
return Some(value);
|
||||
}
|
||||
} else if name == want {
|
||||
// Valueless (boolean) attribute.
|
||||
return Some("");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The byte offset just past the `</name>` that closes an element whose content
|
||||
/// starts at `start`, tracking nesting of same-named tags (e.g. `<g>` inside
|
||||
/// `<g>`). None if the document ends without a matching close.
|
||||
fn element_end(sprite: &str, start: usize, name: &str) -> Option<usize> {
|
||||
let mut i = start;
|
||||
let mut depth = 1usize;
|
||||
while i < sprite.len() {
|
||||
let rest = &sprite[i..];
|
||||
if !rest.starts_with('<') {
|
||||
i += rest.find('<')?;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<!--") {
|
||||
i += rest.find("-->").map(|p| p + 3)?;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<![CDATA[") {
|
||||
i += rest.find("]]>").map(|p| p + 3)?;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<!") || rest.starts_with("<?") {
|
||||
i += rest.find('>').map(|p| p + 1)?;
|
||||
continue;
|
||||
}
|
||||
let gt = i + rest.find('>')?;
|
||||
let inner = &sprite[i + 1..gt];
|
||||
if rest.starts_with("</") {
|
||||
if tag_name(inner) == name {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(gt + 1);
|
||||
}
|
||||
}
|
||||
} else if tag_name(inner) == name && !inner.trim_end().ends_with('/') {
|
||||
depth += 1;
|
||||
}
|
||||
i = gt + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A full-pixmap alpha mask that is opaque only inside `rect`, used to clip a
|
||||
/// blit (e.g. an inline svg raster) to an ancestor's overflow-hidden region.
|
||||
fn rect_clip_mask(width: u32, height: u32, rect: &crate::Rect) -> Option<tiny_skia::Mask> {
|
||||
@@ -1590,4 +1984,54 @@ mod tests {
|
||||
}
|
||||
assert!(found_blue, "expected <use> to instantiate the referenced <rect>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_symbol_by_id_from_sprite() {
|
||||
// The external-sprite core: given a fetched sprite, pull out just the
|
||||
// referenced <symbol> verbatim so it can be spliced into the local svg.
|
||||
let sprite = r##"<svg xmlns="http://www.w3.org/2000/svg"><defs><symbol id="a" viewBox="0 0 10 10"><path d="M0 0h10v10z"/></symbol><symbol id="b"><rect width="4" height="4"/></symbol></defs></svg>"##;
|
||||
let out = extract_svg_element_by_id(sprite, "a").expect("symbol a found");
|
||||
assert!(out.starts_with("<symbol"), "starts at the symbol tag: {out}");
|
||||
assert!(out.contains(r#"id="a""#), "keeps the id: {out}");
|
||||
assert!(out.contains("<path") && out.contains("h10v10z"), "keeps children: {out}");
|
||||
assert!(out.trim_end().ends_with("</symbol>"), "closed at matching end: {out}");
|
||||
assert!(!out.contains(r#"id="b""#), "stops before the sibling symbol: {out}");
|
||||
assert!(!out.contains("<rect"), "no sibling content leaks in: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_handles_self_closing_nesting_and_absent() {
|
||||
// A self-closing element carrying the id returns just that tag.
|
||||
let s1 = r#"<svg><rect id="x" width="4" height="4"/></svg>"#;
|
||||
assert_eq!(
|
||||
extract_svg_element_by_id(s1, "x").as_deref(),
|
||||
Some(r#"<rect id="x" width="4" height="4"/>"#),
|
||||
);
|
||||
// Same-name nesting: the matching close is the outer one, not the inner.
|
||||
let s2 = r#"<svg><g id="grp"><g><path/></g></g></svg>"#;
|
||||
assert_eq!(
|
||||
extract_svg_element_by_id(s2, "grp").as_deref(),
|
||||
Some(r#"<g id="grp"><g><path/></g></g>"#),
|
||||
);
|
||||
// `data-id` / a missing id must not be mistaken for `id`.
|
||||
let s3 = r#"<svg><symbol data-id="a"><path/></symbol></svg>"#;
|
||||
assert!(extract_svg_element_by_id(s3, "a").is_none(), "data-id is not id");
|
||||
assert!(extract_svg_element_by_id(s2, "nope").is_none(), "absent id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_document_use_left_unchanged_by_inject() {
|
||||
// A same-document `<use href="#a">` has an empty url, so inject does no
|
||||
// network work and leaves the serialized markup byte-for-byte unchanged.
|
||||
let tree = parse_html(
|
||||
r##"<html><body><svg viewBox="0 0 10 10"><use href="#a"/><symbol id="a"><path d="M0 0h10v10z"/></symbol></svg></body></html>"##,
|
||||
);
|
||||
let svg = tree.query_selector("svg").unwrap().unwrap();
|
||||
let mut markup = serialize_svg(&tree, svg);
|
||||
let before = markup.clone();
|
||||
let mut cache = std::collections::HashMap::new();
|
||||
let mut sprite_cache = std::collections::HashMap::new();
|
||||
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
|
||||
assert_eq!(markup, before, "same-document use must be untouched");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,17 +253,17 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
||||
"margin-block" => { let (s, e) = two(value); set_margin_side(style, 0, s); set_margin_side(style, 2, e); }
|
||||
"margin-block-start" => set_margin_side(style, 0, value),
|
||||
"margin-block-end" => set_margin_side(style, 2, value),
|
||||
"padding" => { if let Some(e) = edges(value) { style.padding = e; } }
|
||||
"padding-top" => set_edge(&mut style.padding, Side::Top, px(value)),
|
||||
"padding-right" => set_edge(&mut style.padding, Side::Right, px(value)),
|
||||
"padding-bottom" => set_edge(&mut style.padding, Side::Bottom, px(value)),
|
||||
"padding-left" => set_edge(&mut style.padding, Side::Left, px(value)),
|
||||
"padding-inline" => { let (s, e) = two(value); set_edge(&mut style.padding, Side::Left, px(s)); set_edge(&mut style.padding, Side::Right, px(e)); }
|
||||
"padding-inline-start" => set_edge(&mut style.padding, Side::Left, px(value)),
|
||||
"padding-inline-end" => set_edge(&mut style.padding, Side::Right, px(value)),
|
||||
"padding-block" => { let (s, e) = two(value); set_edge(&mut style.padding, Side::Top, px(s)); set_edge(&mut style.padding, Side::Bottom, px(e)); }
|
||||
"padding-block-start" => set_edge(&mut style.padding, Side::Top, px(value)),
|
||||
"padding-block-end" => set_edge(&mut style.padding, Side::Bottom, px(value)),
|
||||
"padding" => apply_padding_shorthand(style, value),
|
||||
"padding-top" => set_padding_side(style, 0, value),
|
||||
"padding-right" => set_padding_side(style, 1, value),
|
||||
"padding-bottom" => set_padding_side(style, 2, value),
|
||||
"padding-left" => set_padding_side(style, 3, value),
|
||||
"padding-inline" => { let (s, e) = two(value); set_padding_side(style, 3, s); set_padding_side(style, 1, e); }
|
||||
"padding-inline-start" => set_padding_side(style, 3, value),
|
||||
"padding-inline-end" => set_padding_side(style, 1, value),
|
||||
"padding-block" => { let (s, e) = two(value); set_padding_side(style, 0, s); set_padding_side(style, 2, e); }
|
||||
"padding-block-start" => set_padding_side(style, 0, value),
|
||||
"padding-block-end" => set_padding_side(style, 2, value),
|
||||
"border-radius" => {
|
||||
// Uniform radius from the first value (ignore per-corner / the
|
||||
// `/` vertical-radius form; the common case is one length).
|
||||
@@ -300,6 +300,12 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
||||
"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),
|
||||
"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/
|
||||
// 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),
|
||||
"border-color" => style.border_color = parse_color(value),
|
||||
"font-size" => {
|
||||
@@ -510,6 +516,9 @@ 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),
|
||||
"transform" => parse_transform(style, value),
|
||||
"box-shadow" | "-webkit-box-shadow" => {
|
||||
style.box_shadow = parse_box_shadow(value, style.color);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1509,19 +1518,86 @@ fn two(value: &str) -> (&str, &str) {
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Set one margin side (0=top,1=right,2=bottom,3=left), tracking `auto`.
|
||||
/// Set one margin side (0=top,1=right,2=bottom,3=left), tracking `auto` and, for
|
||||
/// a percentage, deferring resolution to the containing-block width in the
|
||||
/// top-down pass (recorded in `margin_percent`).
|
||||
fn set_margin_side(style: &mut LayoutStyle, idx: usize, value: &str) {
|
||||
let v = value.trim();
|
||||
let is_auto = v.eq_ignore_ascii_case("auto");
|
||||
if let Some(frac) = percent_fraction(v) {
|
||||
style.margin_percent[idx] = Some(frac);
|
||||
set_margin_px(&mut style.margin, idx, 0.0);
|
||||
style.margin_auto[idx] = false;
|
||||
return;
|
||||
}
|
||||
let px = if is_auto { 0.0 } else { px(value).unwrap_or(0.0) };
|
||||
set_margin_px(&mut style.margin, idx, px);
|
||||
style.margin_auto[idx] = is_auto;
|
||||
style.margin_percent[idx] = None;
|
||||
}
|
||||
|
||||
fn set_margin_px(margin: &mut Edges, idx: usize, px: f32) {
|
||||
match idx {
|
||||
0 => style.margin.top = px,
|
||||
1 => style.margin.right = px,
|
||||
2 => style.margin.bottom = px,
|
||||
3 => style.margin.left = px,
|
||||
0 => margin.top = px,
|
||||
1 => margin.right = px,
|
||||
2 => margin.bottom = px,
|
||||
3 => margin.left = px,
|
||||
_ => {}
|
||||
}
|
||||
style.margin_auto[idx] = is_auto;
|
||||
}
|
||||
|
||||
/// Set one padding side (0=top,1=right,2=bottom,3=left). A percentage is
|
||||
/// recorded in `padding_percent` and resolved against the containing-block
|
||||
/// width during the top-down pass; a length is stored directly.
|
||||
fn set_padding_side(style: &mut LayoutStyle, idx: usize, value: &str) {
|
||||
if let Some(frac) = percent_fraction(value.trim()) {
|
||||
style.padding_percent[idx] = Some(frac);
|
||||
set_padding_px(&mut style.padding, idx, 0.0);
|
||||
return;
|
||||
}
|
||||
if let Some(px) = px(value) {
|
||||
set_padding_px(&mut style.padding, idx, px);
|
||||
style.padding_percent[idx] = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_padding_px(padding: &mut Edges, idx: usize, px: f32) {
|
||||
match idx {
|
||||
0 => padding.top = px,
|
||||
1 => padding.right = px,
|
||||
2 => padding.bottom = px,
|
||||
3 => padding.left = px,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `padding: <t> <r>? <b>? <l>?`, percentage-aware per side.
|
||||
fn apply_padding_shorthand(style: &mut LayoutStyle, value: &str) {
|
||||
let toks: Vec<&str> = value.split_whitespace().collect();
|
||||
let (t, r, b, l) = match toks.as_slice() {
|
||||
[a] => (*a, *a, *a, *a),
|
||||
[v, h] => (*v, *h, *v, *h),
|
||||
[t, h, b] => (*t, *h, *b, *h),
|
||||
[t, r, b, l, ..] => (*t, *r, *b, *l),
|
||||
[] => return,
|
||||
};
|
||||
set_padding_side(style, 0, t);
|
||||
set_padding_side(style, 1, r);
|
||||
set_padding_side(style, 2, b);
|
||||
set_padding_side(style, 3, l);
|
||||
}
|
||||
|
||||
/// A bare `<number>%` token as a 0..1 fraction (`56.25%` -> `0.5625`). Returns
|
||||
/// `None` for anything that is not a plain percentage (lengths, `calc(...%)`,
|
||||
/// keywords), so those keep their existing length handling.
|
||||
fn percent_fraction(tok: &str) -> Option<f32> {
|
||||
let num = tok.trim().strip_suffix('%')?;
|
||||
let v: f32 = num.trim().parse().ok()?;
|
||||
if v.is_finite() {
|
||||
Some(v / 100.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `margin: <t> <r>? <b>? <l>?` with per-side `auto` (so `margin: 0 auto`
|
||||
@@ -1818,6 +1894,86 @@ fn parse_background_position(value: &str) -> (f32, f32) {
|
||||
(x.unwrap_or(0.5), y.unwrap_or(0.5))
|
||||
}
|
||||
|
||||
/// Parse a `box-shadow` value into its first layer:
|
||||
/// `[inset]? <offset-x> <offset-y> <blur>? <spread>? <color>?`. The `inset`
|
||||
/// keyword and the color may each lead or trail the lengths; comma-separated
|
||||
/// multiples are accepted but only the first layer is stored. `current_color`
|
||||
/// supplies the default when the color is omitted (CSS `currentColor`).
|
||||
fn parse_box_shadow(value: &str, current_color: Option<[u8; 4]>) -> Option<crate::BoxShadow> {
|
||||
let v = value.trim();
|
||||
if v.is_empty() || v.eq_ignore_ascii_case("none") {
|
||||
return None;
|
||||
}
|
||||
// Only the first comma-separated layer is modeled; split at paren depth 0 so
|
||||
// the commas inside an rgba()/hsl() color are not treated as separators.
|
||||
let layer = split_top_level(v, ',').into_iter().next()?;
|
||||
let mut inset = false;
|
||||
let mut color: Option<[u8; 4]> = None;
|
||||
let mut lengths: Vec<f32> = Vec::new();
|
||||
for tok in split_ws_paren(layer.trim()) {
|
||||
let t = tok.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if t.eq_ignore_ascii_case("inset") {
|
||||
inset = true;
|
||||
continue;
|
||||
}
|
||||
// Try a length first so a bare `0` is an offset, not a failed color;
|
||||
// px_value rejects color tokens (`#ccc`, `red`, `rgba(...)`) so they
|
||||
// fall through to parse_color.
|
||||
if lengths.len() < 4 {
|
||||
if let Some(px) = px_value(t) {
|
||||
lengths.push(px);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(c) = parse_color(t) {
|
||||
color = Some(c);
|
||||
}
|
||||
}
|
||||
if lengths.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(crate::BoxShadow {
|
||||
offset_x: lengths[0],
|
||||
offset_y: lengths[1],
|
||||
blur: lengths.get(2).copied().unwrap_or(0.0),
|
||||
spread: lengths.get(3).copied().unwrap_or(0.0),
|
||||
color: color.or(current_color).unwrap_or([0, 0, 0, 255]),
|
||||
inset,
|
||||
})
|
||||
}
|
||||
|
||||
/// Split on ASCII whitespace at paren depth 0, so a functional color like
|
||||
/// `rgba(0, 0, 0, .15)` (whose internal spaces are not token separators) stays
|
||||
/// one token.
|
||||
fn split_ws_paren(s: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut start: Option<usize> = None;
|
||||
for (i, c) in s.char_indices() {
|
||||
if c.is_whitespace() && depth == 0 {
|
||||
if let Some(st) = start.take() {
|
||||
out.push(&s[st..i]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth = (depth - 1).max(0),
|
||||
_ => {}
|
||||
}
|
||||
if start.is_none() {
|
||||
start = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(st) = start {
|
||||
out.push(&s[st..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// CSS box shorthand: 1 value applies to all sides; 2 values are (v, h);
|
||||
/// 3 values are (top, h, bottom); 4 values are (top, right, bottom, left).
|
||||
fn edges_from(dims: Vec<f32>) -> Option<Edges> {
|
||||
@@ -1855,12 +2011,46 @@ mod tests {
|
||||
assert_eq!(s.padding.left, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentage_padding_recorded_not_pixelized() {
|
||||
// A percentage padding must be deferred (recorded as a 0..1 fraction),
|
||||
// not eagerly converted to a bogus px value: it resolves against the
|
||||
// containing block width later, in the top-down pass.
|
||||
let s = compute_style("div", Some("padding-top: 56.25%"));
|
||||
assert_eq!(s.padding_percent[0], Some(0.5625));
|
||||
assert_eq!(s.padding.top, 0.0);
|
||||
|
||||
// The shorthand splits per side, so a mix of length and percent lands
|
||||
// in the right buckets.
|
||||
let s = compute_style("div", Some("padding: 10px 25%"));
|
||||
assert_eq!(s.padding.top, 10.0);
|
||||
assert_eq!(s.padding_percent[0], None);
|
||||
assert_eq!(s.padding_percent[1], Some(0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentage_margin_recorded() {
|
||||
let s = compute_style("div", Some("margin-left: 10%"));
|
||||
assert_eq!(s.margin_percent[3], Some(0.1));
|
||||
assert!(!s.margin_auto[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ua_defaults_and_ignore_unknown() {
|
||||
let s = compute_style("span", Some("color: red; ; bogus: ; display: none"));
|
||||
assert_eq!(s.display, Display::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_clip_text_flag() {
|
||||
let s = compute_style("h1", Some("color: transparent; -webkit-background-clip: text"));
|
||||
assert!(s.background_clip_text);
|
||||
let n = compute_style("h1", Some("background-clip: border-box"));
|
||||
assert!(!n.background_clip_text);
|
||||
let l = compute_style("h1", Some("background-clip: text"));
|
||||
assert!(l.background_clip_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn important_and_auto() {
|
||||
let s = compute_style("div", Some("width: 100px !important; height: auto"));
|
||||
@@ -1938,4 +2128,42 @@ mod tests {
|
||||
assert_eq!(s.flex_grow, Some(2.0));
|
||||
assert_eq!(s.flex_shrink, Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_shadow_outset_parses() {
|
||||
let s = compute_style("div", Some("box-shadow: 0 2px 8px rgba(0,0,0,.15)"));
|
||||
let sh = s.box_shadow.expect("box-shadow parsed");
|
||||
assert!(!sh.inset);
|
||||
assert_eq!(sh.offset_x, 0.0);
|
||||
assert_eq!(sh.offset_y, 2.0);
|
||||
assert_eq!(sh.blur, 8.0);
|
||||
assert_eq!(sh.spread, 0.0);
|
||||
assert_eq!(sh.color, [0, 0, 0, 38]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_shadow_inset_parses() {
|
||||
let s = compute_style("div", Some("box-shadow: inset 0 0 0 1px #ccc"));
|
||||
let sh = s.box_shadow.expect("box-shadow parsed");
|
||||
assert!(sh.inset);
|
||||
assert_eq!(sh.offset_x, 0.0);
|
||||
assert_eq!(sh.offset_y, 0.0);
|
||||
assert_eq!(sh.blur, 0.0);
|
||||
assert_eq!(sh.spread, 1.0);
|
||||
assert_eq!(sh.color, [204, 204, 204, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_shadow_color_defaults_to_current_color() {
|
||||
// No explicit color: falls back to the element's text color.
|
||||
let s = compute_style("div", Some("color: red; box-shadow: 1px 1px 2px"));
|
||||
let sh = s.box_shadow.expect("box-shadow parsed");
|
||||
assert_eq!(sh.color, [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_shadow_none_clears() {
|
||||
let s = compute_style("div", Some("box-shadow: none"));
|
||||
assert!(s.box_shadow.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,27 @@ fn relative_units_resolve_against_viewport_and_font_size() {
|
||||
assert!(hit, "expected a 500x160 box from 50vw/10em, rects: {:?}", layout.rects.values().map(|r| (r.width, r.height)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentage_padding_top_reserves_aspect_ratio_box() {
|
||||
// The responsive aspect-ratio trick: an empty box with padding-top:56.25%
|
||||
// inside a 1000px-wide block reserves a 16:9 area (~562px tall), the room a
|
||||
// `position:absolute; inset:0` media child fills. Percentage padding
|
||||
// resolves against the containing block WIDTH on every side, so the box
|
||||
// gains real height instead of collapsing to zero.
|
||||
let html = r##"<div style="width:1000px"><div style="padding-top:56.25%"></div></div>"##;
|
||||
let tree = parse_html(html);
|
||||
let layout = layout_dom(&tree, (1200.0, 800.0));
|
||||
let hit = layout
|
||||
.rects
|
||||
.values()
|
||||
.any(|r| (r.width - 1000.0).abs() < 1.0 && (r.height - 562.5).abs() < 2.0);
|
||||
assert!(
|
||||
hit,
|
||||
"expected a ~1000x562 aspect-ratio box from padding-top:56.25%, rects: {:?}",
|
||||
layout.rects.values().map(|r| (r.width, r.height)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_text_run_wraps_across_multiple_lines() {
|
||||
// A long single text node with no inline elements breaking it up must
|
||||
|
||||
Reference in New Issue
Block a user