render: object-fit, transform translate/scale, inline-svg rasterization
object-fit: contain/cover/scale-down/none for <img> so an image whose aspect ratio differs from its box is fit (letterbox/crop) instead of stretched. transform: parse translate()/translateX/translateY (and scale); apply translate as a paint-time offset to the element and its subtree, so translate(-50%,-50%) centering and off-screen skip-links land correctly. inline-svg: serialize and rasterize inline <svg> subtrees via resvg so icon sprites and brand logos paint instead of showing blank.
This commit is contained in:
1 parent
2652ee64a4
commit
e27a3bc5b0
4 files changed
+721
-30
No files matched your search
@@ -416,12 +416,16 @@ pub fn content_width(rect: &Rect, style: &LayoutStyle) -> f32 {
|
||||
impl TextEngine {
|
||||
/// Rasterize inline context `idx` into `pixmap`, honoring its clip. Uses
|
||||
/// cosmic-text's swash-backed rasterizer (anti-aliased, per-glyph color
|
||||
/// from the span attributes).
|
||||
pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap) {
|
||||
/// from the span attributes). `offset` is the accumulated `transform:
|
||||
/// translate()` of the container's ancestors, shifting both the glyph
|
||||
/// origin and the clip so shaped text moves with a transformed box.
|
||||
pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap, offset: (f32, f32)) {
|
||||
let TextEngine { font_system, swash, items } = self;
|
||||
let Some(item) = items.get_mut(idx) else { return };
|
||||
let (ox, oy) = item.origin;
|
||||
let clip = item.clip;
|
||||
let (ox, oy) = (item.origin.0 + offset.0, item.origin.1 + offset.1);
|
||||
let clip = item
|
||||
.clip
|
||||
.map(|c| Rect { x: c.x + offset.0, y: c.y + offset.1, width: c.width, height: c.height });
|
||||
let pw = pixmap.width() as i32;
|
||||
let ph = pixmap.height() as i32;
|
||||
let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height));
|
||||
|
||||
@@ -345,6 +345,27 @@ pub struct LayoutStyle {
|
||||
/// shaping (we embed the DejaVu Sans oblique/bold-oblique faces). `None`
|
||||
/// means inherit.
|
||||
pub font_style_italic: Option<bool>,
|
||||
|
||||
/// `object-fit` for a replaced element (`<img>`). Controls how the decoded
|
||||
/// image is scaled into the element's box when their aspect ratios differ;
|
||||
/// `Fill` (default) stretches to the box, the rest preserve aspect ratio.
|
||||
/// Only consulted in the image paint path.
|
||||
pub object_fit: ObjectFit,
|
||||
/// `transform: translate(x[, y])` / `translateX` / `translateY`, stored
|
||||
/// unresolved as (dx, dy). Applied at paint time as an offset to the
|
||||
/// element's own box AND its whole descendant subtree; percentages resolve
|
||||
/// against the element's own border-box size then. This is what makes the
|
||||
/// canonical `translate(-50%,-50%)` centering of an absolutely-positioned
|
||||
/// box land in the right place, and moves a `translate(-9999px,0)`
|
||||
/// off-screen skip-link out of view instead of painting it on-screen. Not
|
||||
/// inherited (transform is a non-inherited property).
|
||||
pub transform_translate: Option<(Dimension, Dimension)>,
|
||||
/// `transform: scale(sx[, sy])` / `scaleX` / `scaleY`, captured as (sx, sy).
|
||||
/// Parsed and stored so a value that mixes scale with translate still yields
|
||||
/// its translate part; scale is not yet folded into paint geometry (doing so
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -353,6 +374,22 @@ pub enum Float {
|
||||
Right,
|
||||
}
|
||||
|
||||
/// `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
|
||||
/// ratio, either letterboxing inside the box (`Contain`), cropping to cover it
|
||||
/// (`Cover`), or using the intrinsic size (`None`, or `ScaleDown` which is
|
||||
/// `Contain` capped at the intrinsic size so it never upscales).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ObjectFit {
|
||||
#[default]
|
||||
Fill,
|
||||
Contain,
|
||||
Cover,
|
||||
ScaleDown,
|
||||
None,
|
||||
}
|
||||
|
||||
/// `line-height`: `normal` (a font-relative default), a unitless multiple of
|
||||
/// font-size, or an absolute pixel length.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
|
||||
@@ -34,15 +34,28 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
// each URL is still fetched at most once.
|
||||
let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache);
|
||||
let mut laid = layout_dom_with_images(tree, viewport, &intrinsic);
|
||||
// Nodes that live inside an inline `<svg>` we rasterized as one document;
|
||||
// their painting is owned by that raster, so they are skipped in both the
|
||||
// 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();
|
||||
// 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
|
||||
// path free of any added cost.
|
||||
let any_transform = laid.styles.values().any(|s| s.transform_translate.is_some());
|
||||
// Tree order so later elements paint over earlier ones (normal flow).
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
if svg_subtree_skip.contains(&nid) {
|
||||
continue;
|
||||
}
|
||||
let node = match tree.get_node(nid) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
|
||||
if node.is_text() {
|
||||
paint_text_node(tree, nid, &laid, &mut pixmap);
|
||||
paint_text_node(tree, nid, &laid, &mut pixmap, any_transform);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -51,7 +64,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
None => continue,
|
||||
};
|
||||
let rect = match laid.rects.get(&nid) {
|
||||
Some(r) => r,
|
||||
Some(r) => *r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
@@ -64,17 +77,33 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
continue;
|
||||
}
|
||||
|
||||
// A `transform: translate()` on this element or any ancestor offsets
|
||||
// this element's whole painted box (and, applied per node, its whole
|
||||
// subtree). Shift the border box and the inherited clip by the same
|
||||
// accumulated vector so everything painted for this node moves together.
|
||||
let (ox, oy) = if any_transform {
|
||||
accumulated_translate(tree, nid, &laid)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
let rect = crate::Rect { x: rect.x + ox, y: rect.y + oy, width: rect.width, height: rect.height };
|
||||
|
||||
// Ancestor `overflow: hidden` clip, if any. Skip painting entirely
|
||||
// once the box has no visible overlap with it (this is what makes the
|
||||
// ubiquitous 1x1 clipped "visually hidden" accessibility pattern
|
||||
// actually invisible instead of painting text wherever it lands).
|
||||
let clip = laid.clip_rects.get(&nid).copied().flatten();
|
||||
let clip = laid
|
||||
.clip_rects
|
||||
.get(&nid)
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|c| crate::Rect { x: c.x + ox, y: c.y + oy, width: c.width, height: c.height });
|
||||
let visible_rect = match clip {
|
||||
Some(c) => match rect.intersect(&c) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
},
|
||||
None => *rect,
|
||||
None => rect,
|
||||
};
|
||||
let box_rect = match Rect::from_xywh(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height) {
|
||||
Some(r) => r,
|
||||
@@ -129,14 +158,16 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
height: ih,
|
||||
}
|
||||
}
|
||||
None => *rect,
|
||||
None => rect,
|
||||
};
|
||||
let img_rect = match clip {
|
||||
Some(c) => img_rect.intersect(&c),
|
||||
None => Some(img_rect),
|
||||
};
|
||||
if let Some(img_rect) = img_rect {
|
||||
paint_image(bg_url, base_url, &img_rect, &mut pixmap, &mut image_cache);
|
||||
// object-fit does not apply to background images (that is
|
||||
// background-size, handled above); paint the layer as-is.
|
||||
paint_image(bg_url, base_url, &img_rect, crate::ObjectFit::Fill, &mut pixmap, &mut image_cache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +222,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
|
||||
if name.local.as_ref() == "img" {
|
||||
if let Some(src) = resolve_img_url(tree, nid) {
|
||||
let painted = paint_image(&src, base_url, &rect, &mut pixmap, &mut image_cache);
|
||||
let painted = paint_image(&src, base_url, &rect, style.object_fit, &mut pixmap, &mut image_cache);
|
||||
// Fall back to alt text only when the image itself did not paint.
|
||||
if !painted {
|
||||
// A fetch/decode failure paints a neutral grey box (like a
|
||||
@@ -213,6 +244,31 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
}
|
||||
}
|
||||
|
||||
// Inline `<svg>...</svg>`: serialize the whole subtree back to one
|
||||
// standalone SVG document and rasterize it as a unit, so a
|
||||
// `<use href="#id">` resolves against the `<symbol>`/`<defs>` in the
|
||||
// same svg. The raster owns the subtree, so its DOM children are not
|
||||
// painted individually (they are added to `svg_subtree_skip`). The svg
|
||||
// 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);
|
||||
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(
|
||||
rect.x as i32,
|
||||
rect.y as i32,
|
||||
content.as_ref(),
|
||||
&tiny_skia::PixmapPaint::default(),
|
||||
Transform::identity(),
|
||||
mask.as_ref(),
|
||||
);
|
||||
}
|
||||
for child in tree.descendants(nid) {
|
||||
svg_subtree_skip.insert(child);
|
||||
}
|
||||
}
|
||||
|
||||
// List-item marker (bullet or number), drawn in the indent to the left
|
||||
// of the item's content box. `list_style` is inherited and resolved,
|
||||
// so `None` (e.g. a nav `<ul style="list-style:none">`) suppresses it.
|
||||
@@ -236,7 +292,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
let fsize = style.font_size.unwrap_or(16.0);
|
||||
let is_bold = style.font_weight.as_deref() == Some("bold");
|
||||
for (word_rect, word) in runs {
|
||||
draw_text(&mut pixmap, word, word_rect.x, word_rect.y, color, fsize, is_bold, clip);
|
||||
draw_text(&mut pixmap, word, word_rect.x + ox, word_rect.y + oy, color, fsize, is_bold, clip);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,17 +320,71 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
// box backgrounds/borders painted in the loop above. Each item already
|
||||
// carries its final origin and clip from `TextEngine::finalize`.
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
if svg_subtree_skip.contains(&nid) {
|
||||
continue;
|
||||
}
|
||||
if let Some(&idx) = laid.ifc_items.get(&nid) {
|
||||
if laid.styles.get(&nid).map(|s| s.effectively_invisible).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
laid.text_engine.paint_item(idx, &mut pixmap);
|
||||
// Shift the shaped glyphs by the same accumulated translate as the
|
||||
// container's box so text under a transformed ancestor moves with
|
||||
// it. Computed before the mutable `paint_item` borrow.
|
||||
let off = if any_transform {
|
||||
accumulated_translate(tree, nid, &laid)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
laid.text_engine.paint_item(idx, &mut pixmap, off);
|
||||
}
|
||||
}
|
||||
|
||||
Some(pixmap)
|
||||
}
|
||||
|
||||
/// Sum of `transform: translate()` offsets of `nid` and every ancestor, in CSS
|
||||
/// px. A translate shifts an element's own box and its entire subtree by the
|
||||
/// same vector, so a node's painted position is its normal-flow position plus
|
||||
/// every translate at or above it. Percentage components resolve against each
|
||||
/// contributing element's own border box (read from the laid-out rects), per
|
||||
/// CSS. Nodes with no style/rect entry (e.g. text nodes) contribute nothing but
|
||||
/// are still walked through so element ancestors above them count.
|
||||
fn accumulated_translate(
|
||||
tree: &DomTree,
|
||||
nid: obscura_dom::tree::NodeId,
|
||||
laid: &crate::DomLayout,
|
||||
) -> (f32, f32) {
|
||||
let (mut ox, mut oy) = (0.0f32, 0.0f32);
|
||||
let mut cur = Some(nid);
|
||||
while let Some(id) = cur {
|
||||
if let (Some(style), Some(rect)) = (laid.styles.get(&id), laid.rects.get(&id)) {
|
||||
if let Some((dx, dy)) = style.transform_translate {
|
||||
ox += resolve_translate(dx, rect.width);
|
||||
oy += resolve_translate(dy, rect.height);
|
||||
}
|
||||
}
|
||||
cur = tree.get_node(id).and_then(|n| n.parent);
|
||||
}
|
||||
(ox, oy)
|
||||
}
|
||||
|
||||
/// Resolve one translate component to px: a length passes through, a percentage
|
||||
/// is taken against `basis` (the element's own border-box extent on that axis).
|
||||
/// Font/viewport-relative leftovers fall back to a coarse px value rather than
|
||||
/// panicking (translate rarely uses them; px/% are the required forms).
|
||||
fn resolve_translate(d: crate::Dimension, basis: f32) -> f32 {
|
||||
match d {
|
||||
crate::Dimension::Px(px) => px,
|
||||
crate::Dimension::Percent(p) => p * basis,
|
||||
crate::Dimension::Em(v) | crate::Dimension::Rem(v) => v * 16.0,
|
||||
crate::Dimension::Vw(v)
|
||||
| crate::Dimension::Vh(v)
|
||||
| crate::Dimension::Vmin(v)
|
||||
| crate::Dimension::Vmax(v) => v,
|
||||
crate::Dimension::Auto => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed rounded-rectangle path, corners approximated by quadratic curves
|
||||
/// (visually indistinguishable from true arcs at typical UI radii). `r` is
|
||||
/// clamped so it never exceeds half the shorter side.
|
||||
@@ -336,6 +446,7 @@ fn paint_text_node(
|
||||
nid: obscura_dom::tree::NodeId,
|
||||
laid: &crate::DomLayout,
|
||||
pixmap: &mut Pixmap,
|
||||
any_transform: bool,
|
||||
) -> Option<()> {
|
||||
let runs = laid.text_runs.get(&nid)?;
|
||||
let node = tree.get_node(nid)?;
|
||||
@@ -347,10 +458,22 @@ fn paint_text_node(
|
||||
let color = 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");
|
||||
let clip = laid.clip_rects.get(&nid).copied().flatten();
|
||||
// A text node has no transform of its own, but any transformed element
|
||||
// ancestor offsets it; the ancestor walk from the text node picks those up.
|
||||
let (ox, oy) = if any_transform {
|
||||
accumulated_translate(tree, nid, laid)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
let clip = laid
|
||||
.clip_rects
|
||||
.get(&nid)
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|c| crate::Rect { x: c.x + ox, y: c.y + oy, width: c.width, height: c.height });
|
||||
|
||||
for (rect, word) in runs {
|
||||
draw_text(pixmap, word, rect.x, rect.y, color, fsize, is_bold, clip);
|
||||
draw_text(pixmap, word, rect.x + ox, rect.y + oy, color, fsize, is_bold, clip);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
@@ -897,6 +1020,7 @@ fn paint_image(
|
||||
src: &str,
|
||||
base_url: Option<&str>,
|
||||
rect: &crate::Rect,
|
||||
object_fit: crate::ObjectFit,
|
||||
pixmap: &mut Pixmap,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> bool {
|
||||
@@ -904,24 +1028,113 @@ fn paint_image(
|
||||
return false;
|
||||
}
|
||||
let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false };
|
||||
let svg = is_svg(&bytes);
|
||||
|
||||
let content = if is_svg(&bytes) {
|
||||
render_svg(&bytes, rect.width as u32, rect.height as u32)
|
||||
// Destination sub-rect within the element box. `Fill` keeps the historical
|
||||
// behavior (stretch the image to the whole box); the other modes need the
|
||||
// image's intrinsic size to preserve its aspect ratio, and fall back to
|
||||
// fill when it cannot be read.
|
||||
let dest = if object_fit == crate::ObjectFit::Fill {
|
||||
*rect
|
||||
} else {
|
||||
raster_to_pixmap(&bytes, rect.width as u32, rect.height as u32)
|
||||
let intrinsic = if svg {
|
||||
svg_intrinsic(&bytes)
|
||||
} else {
|
||||
image_dimensions(&bytes).map(|(w, h)| (w as f32, h as f32))
|
||||
};
|
||||
match intrinsic {
|
||||
Some((iw, ih)) => object_fit_dest(rect, iw, ih, object_fit),
|
||||
None => *rect,
|
||||
}
|
||||
};
|
||||
if let Some(content) = content {
|
||||
pixmap.draw_pixmap(
|
||||
rect.x as i32,
|
||||
rect.y as i32,
|
||||
content.as_ref(),
|
||||
&tiny_skia::PixmapPaint::default(),
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
return true;
|
||||
|
||||
let (dw, dh) = (dest.width.round().max(1.0) as u32, dest.height.round().max(1.0) as u32);
|
||||
let content = if svg {
|
||||
render_svg(&bytes, dw, dh)
|
||||
} else {
|
||||
raster_to_pixmap(&bytes, dw, dh)
|
||||
};
|
||||
let Some(content) = content else { return false };
|
||||
|
||||
// `Cover`/`None` can size the image past the box; clip it to the box so it
|
||||
// does not paint over neighboring content (CSS clips object-fit to the
|
||||
// content box). `Fill`/`Contain`/`scale-down` always fit inside the box, so
|
||||
// they take the faster unclipped path.
|
||||
let clip = if dest.width > rect.width + 0.5 || dest.height > rect.height + 0.5 {
|
||||
box_clip_mask(pixmap.width(), pixmap.height(), rect)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
pixmap.draw_pixmap(
|
||||
dest.x as i32,
|
||||
dest.y as i32,
|
||||
content.as_ref(),
|
||||
&tiny_skia::PixmapPaint::default(),
|
||||
Transform::identity(),
|
||||
clip.as_ref(),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// The destination sub-rect for a replaced element's image within its box,
|
||||
/// given the image's intrinsic `(iw, ih)` size and `object-fit`. Centered in
|
||||
/// the box; for `Cover`/`None` it can extend past the box edges (the caller
|
||||
/// clips it). Aspect ratio is preserved for every mode except `Fill`.
|
||||
fn object_fit_dest(box_rect: &crate::Rect, iw: f32, ih: f32, fit: crate::ObjectFit) -> crate::Rect {
|
||||
let (bw, bh) = (box_rect.width, box_rect.height);
|
||||
if iw <= 0.0 || ih <= 0.0 {
|
||||
return *box_rect;
|
||||
}
|
||||
false
|
||||
let (dw, dh) = match fit {
|
||||
crate::ObjectFit::Fill => (bw, bh),
|
||||
crate::ObjectFit::Contain => {
|
||||
let s = (bw / iw).min(bh / ih);
|
||||
(iw * s, ih * s)
|
||||
}
|
||||
crate::ObjectFit::Cover => {
|
||||
let s = (bw / iw).max(bh / ih);
|
||||
(iw * s, ih * s)
|
||||
}
|
||||
crate::ObjectFit::None => (iw, ih),
|
||||
crate::ObjectFit::ScaleDown => {
|
||||
// min(Contain-size, intrinsic-size): the Contain fit, but never
|
||||
// scaled up past the image's own pixels.
|
||||
let s = (bw / iw).min(bh / ih).min(1.0);
|
||||
(iw * s, ih * s)
|
||||
}
|
||||
};
|
||||
crate::Rect {
|
||||
x: box_rect.x + (bw - dw) / 2.0,
|
||||
y: box_rect.y + (bh - dh) / 2.0,
|
||||
width: dw,
|
||||
height: dh,
|
||||
}
|
||||
}
|
||||
|
||||
/// The intrinsic `(width, height)` of an SVG image from its size/`viewBox`,
|
||||
/// used to preserve aspect ratio under `object-fit`. Parses the SVG once; the
|
||||
/// eventual raster re-parses in `render_svg` (only reached for a non-`fill`
|
||||
/// object-fit on an SVG image, which is rare).
|
||||
fn svg_intrinsic(bytes: &[u8]) -> Option<(f32, f32)> {
|
||||
let tree = usvg::Tree::from_data(bytes, &usvg::Options::default()).ok()?;
|
||||
let size = tree.size();
|
||||
if size.width() > 0.0 && size.height() > 0.0 {
|
||||
Some((size.width(), size.height()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A full-pixmap clip mask admitting only the pixels inside `rect`, used to
|
||||
/// crop an `object-fit: cover|none` image to its element box.
|
||||
fn box_clip_mask(pw: u32, ph: u32, rect: &crate::Rect) -> Option<tiny_skia::Mask> {
|
||||
let mut mask = tiny_skia::Mask::new(pw, ph)?;
|
||||
let r = Rect::from_xywh(rect.x, rect.y, rect.width, rect.height)?;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_rect(r);
|
||||
let path = pb.finish()?;
|
||||
mask.fill_path(&path, FillRule::Winding, true, Transform::identity());
|
||||
Some(mask)
|
||||
}
|
||||
|
||||
/// Sniff SVG content: either an XML/SVG prolog, or a bare `<svg` root tag
|
||||
@@ -951,6 +1164,106 @@ fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option<Pixmap> {
|
||||
Some(svg_pixmap)
|
||||
}
|
||||
|
||||
/// Serialize an inline `<svg>` subtree (rooted at `root`) back to a standalone
|
||||
/// SVG document string. Emits `<tag attr="v">children</tag>` for the element
|
||||
/// and every descendant, preserving the root's `viewBox`/`width`/`height` and
|
||||
/// all `<defs>`/`<symbol>`/`<use>`/`<path>` structure so resvg can rasterize it
|
||||
/// as a self-contained document. SVG is XML-clean, so there are no HTML
|
||||
/// void-element or optional-close rules to apply; every element gets an
|
||||
/// explicit closing tag. The root gains an `xmlns` declaration when it lacks
|
||||
/// one (common for inline svg, whose namespace is implied by the HTML parser
|
||||
/// but required for usvg to parse the string on its own).
|
||||
fn serialize_svg(tree: &DomTree, root: obscura_dom::tree::NodeId) -> String {
|
||||
let mut buf = String::new();
|
||||
serialize_svg_node(tree, root, true, &mut buf);
|
||||
buf
|
||||
}
|
||||
|
||||
fn serialize_svg_node(tree: &DomTree, nid: obscura_dom::tree::NodeId, is_root: bool, buf: &mut String) {
|
||||
let node = match tree.get_node(nid) {
|
||||
Some(n) => n,
|
||||
None => return,
|
||||
};
|
||||
if let Some(text) = node.text_content_of_text_node() {
|
||||
svg_escape_text(text, buf);
|
||||
return;
|
||||
}
|
||||
let name = match node.as_element() {
|
||||
Some(n) => n,
|
||||
// Document/comment/PI: no tag of its own, emit only element children.
|
||||
None => {
|
||||
for child in tree.children(nid) {
|
||||
serialize_svg_node(tree, child, false, buf);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let tag = name.local.as_ref();
|
||||
buf.push('<');
|
||||
buf.push_str(tag);
|
||||
let mut has_xmlns = false;
|
||||
if let Some(attrs) = node.attrs() {
|
||||
for attr in attrs {
|
||||
// Emit the local name only, dropping any prefix (`xlink:href` ->
|
||||
// `href`): resvg reads both, and a bare local avoids needing an
|
||||
// `xmlns:xlink` declaration in the standalone document.
|
||||
let aname = attr.name.local.as_ref();
|
||||
if aname == "xmlns" {
|
||||
has_xmlns = true;
|
||||
}
|
||||
buf.push(' ');
|
||||
buf.push_str(aname);
|
||||
buf.push_str("=\"");
|
||||
svg_escape_attr(&attr.value, buf);
|
||||
buf.push('"');
|
||||
}
|
||||
}
|
||||
if is_root && !has_xmlns {
|
||||
buf.push_str(" xmlns=\"http://www.w3.org/2000/svg\"");
|
||||
}
|
||||
buf.push('>');
|
||||
for child in tree.children(nid) {
|
||||
serialize_svg_node(tree, child, false, buf);
|
||||
}
|
||||
buf.push_str("</");
|
||||
buf.push_str(tag);
|
||||
buf.push('>');
|
||||
}
|
||||
|
||||
fn svg_escape_text(s: &str, buf: &mut String) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => buf.push_str("&"),
|
||||
'<' => buf.push_str("<"),
|
||||
'>' => buf.push_str(">"),
|
||||
_ => buf.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn svg_escape_attr(s: &str, buf: &mut String) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => buf.push_str("&"),
|
||||
'<' => buf.push_str("<"),
|
||||
'"' => buf.push_str("""),
|
||||
_ => buf.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut mask = tiny_skia::Mask::new(width, height)?;
|
||||
let r = Rect::from_xywh(rect.x, rect.y, rect.width, rect.height)?;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_rect(r);
|
||||
let path = pb.finish()?;
|
||||
mask.fill_path(&path, FillRule::Winding, true, Transform::identity());
|
||||
Some(mask)
|
||||
}
|
||||
|
||||
/// Paint a `mask-image`: the ubiquitous "colored, scalable icon" pattern,
|
||||
/// where an SVG shape is used purely as a stencil and tinted by
|
||||
/// `background-color`/`color` rather than carrying its own colors. Fetches
|
||||
@@ -1011,6 +1324,55 @@ mod tests {
|
||||
use super::*;
|
||||
use obscura_dom::tree_sink::parse_html;
|
||||
|
||||
#[test]
|
||||
fn object_fit_contain_and_cover_center_and_preserve_aspect() {
|
||||
// A 200x100 box (2:1) with a square 100x100 image, offset so centering
|
||||
// is checked against the box origin, not (0,0).
|
||||
let box_rect = crate::Rect { x: 10.0, y: 20.0, width: 200.0, height: 100.0 };
|
||||
let (iw, ih) = (100.0f32, 100.0f32);
|
||||
|
||||
// Contain: the largest square fitting inside 200x100 is 100x100,
|
||||
// letterboxed horizontally and centered.
|
||||
let c = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Contain);
|
||||
assert!((c.width - 100.0).abs() < 0.01 && (c.height - 100.0).abs() < 0.01, "contain size {:?}", c);
|
||||
assert!((c.width / c.height - iw / ih).abs() < 1e-3, "contain preserves aspect: {:?}", c);
|
||||
assert!((c.x - 60.0).abs() < 0.01, "contain centered x (10 + (200-100)/2): {}", c.x);
|
||||
assert!((c.y - 20.0).abs() < 0.01, "contain centered y (20 + (100-100)/2): {}", c.y);
|
||||
// Contain always fits inside the box.
|
||||
assert!(c.x >= box_rect.x - 0.01 && c.x + c.width <= box_rect.x + box_rect.width + 0.01);
|
||||
assert!(c.y >= box_rect.y - 0.01 && c.y + c.height <= box_rect.y + box_rect.height + 0.01);
|
||||
|
||||
// Cover: the smallest square covering 200x100 is 200x200, centered so
|
||||
// it overflows the box vertically (the paint path clips it).
|
||||
let v = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Cover);
|
||||
assert!((v.width - 200.0).abs() < 0.01 && (v.height - 200.0).abs() < 0.01, "cover size {:?}", v);
|
||||
assert!((v.width / v.height - iw / ih).abs() < 1e-3, "cover preserves aspect: {:?}", v);
|
||||
assert!((v.x - 10.0).abs() < 0.01, "cover centered x (10 + (200-200)/2): {}", v.x);
|
||||
assert!((v.y + 30.0).abs() < 0.01, "cover centered y (20 + (100-200)/2 = -30): {}", v.y);
|
||||
// Cover fully covers the box on both axes.
|
||||
assert!(v.x <= box_rect.x + 0.01 && v.x + v.width >= box_rect.x + box_rect.width - 0.01);
|
||||
assert!(v.y <= box_rect.y + 0.01 && v.y + v.height >= box_rect.y + box_rect.height - 0.01);
|
||||
|
||||
// scale-down never upscales: a 100x100 image in a 200x200 box stays
|
||||
// 100x100 (Contain would grow it to 200x200), centered.
|
||||
let box2 = crate::Rect { x: 0.0, y: 0.0, width: 200.0, height: 200.0 };
|
||||
let sd = object_fit_dest(&box2, iw, ih, crate::ObjectFit::ScaleDown);
|
||||
assert!((sd.width - 100.0).abs() < 0.01 && (sd.height - 100.0).abs() < 0.01, "scale-down no upscale: {:?}", sd);
|
||||
assert!((sd.x - 50.0).abs() < 0.01 && (sd.y - 50.0).abs() < 0.01, "scale-down centered: {:?}", sd);
|
||||
let cn = object_fit_dest(&box2, iw, ih, crate::ObjectFit::Contain);
|
||||
assert!((cn.width - 200.0).abs() < 0.01, "contain upscales into the box: {:?}", cn);
|
||||
|
||||
// None uses the intrinsic size regardless of box, centered.
|
||||
let n = object_fit_dest(&box2, iw, ih, crate::ObjectFit::None);
|
||||
assert!((n.width - 100.0).abs() < 0.01 && (n.height - 100.0).abs() < 0.01, "none intrinsic size: {:?}", n);
|
||||
assert!((n.x - 50.0).abs() < 0.01 && (n.y - 50.0).abs() < 0.01, "none centered: {:?}", n);
|
||||
|
||||
// Fill stretches to exactly the box.
|
||||
let f = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Fill);
|
||||
assert!((f.width - box_rect.width).abs() < 0.01 && (f.height - box_rect.height).abs() < 0.01, "fill: {:?}", f);
|
||||
assert!((f.x - box_rect.x).abs() < 0.01 && (f.y - box_rect.y).abs() < 0.01, "fill origin: {:?}", f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_background_color() {
|
||||
let tree = parse_html(
|
||||
@@ -1046,6 +1408,93 @@ mod tests {
|
||||
assert!(p.blue() > 200, "expected blue to paint over red, got {:?}", p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_translate_accumulates_through_subtree() {
|
||||
// Parent red box (position:absolute at 0,0, 20x20) translated by
|
||||
// (50,60). Child blue box (10x10, in-flow at the red box's origin)
|
||||
// translated by an additional (30,0). The child's painted position must
|
||||
// be the SUM of both translates, (50+30, 60+0) = (80,60), proving an
|
||||
// ancestor's translate offsets the whole subtree on top of the node's
|
||||
// own translate.
|
||||
let tree = parse_html(
|
||||
"<html><body style=\"margin:0\">\
|
||||
<div style=\"position:relative; width:200px; height:200px\">\
|
||||
<div style=\"position:absolute; top:0; left:0; width:20px; height:20px; \
|
||||
background:#ff0000; transform:translate(50px,60px)\">\
|
||||
<div style=\"width:10px; height:10px; background:#0000ff; \
|
||||
transform:translate(30px,0)\"></div>\
|
||||
</div>\
|
||||
</div>\
|
||||
</body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
// Child blue lands at (80..90, 60..70).
|
||||
let blue = pixmap.pixel(85, 65).expect("pixel");
|
||||
assert!(
|
||||
blue.blue() > 200 && blue.red() < 60,
|
||||
"expected blue child at accumulated offset (80,60), got {:?}",
|
||||
blue
|
||||
);
|
||||
// Parent red lands at (50..70, 60..80); sample where the blue child does
|
||||
// not cover.
|
||||
let red = pixmap.pixel(55, 75).expect("pixel");
|
||||
assert!(
|
||||
red.red() > 200 && red.blue() < 60,
|
||||
"expected red parent at its own translate (50,60), got {:?}",
|
||||
red
|
||||
);
|
||||
// Nothing painted at the pre-transform origin: both boxes moved away.
|
||||
let origin = pixmap.pixel(5, 5).expect("pixel");
|
||||
assert_eq!((origin.red(), origin.green(), origin.blue()), (255, 255, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_offscreen_box_is_not_painted() {
|
||||
// translate(-10000px,0) shoves the box far off the left edge (the old
|
||||
// hidden skip-link idiom); it must not paint anywhere on the canvas.
|
||||
let tree = parse_html(
|
||||
"<html><body>\
|
||||
<div style=\"position:absolute; top:0; left:0; width:50px; height:50px; \
|
||||
background:#ff0000; transform:translate(-10000px,0)\"></div>\
|
||||
</body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let mut any_red = false;
|
||||
'scan: for y in 0..200 {
|
||||
for x in 0..200 {
|
||||
let p = pixmap.pixel(x, y).expect("pixel");
|
||||
if p.red() > 200 && p.green() < 60 && p.blue() < 60 {
|
||||
any_red = true;
|
||||
break 'scan;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!any_red, "translate(-10000px,0) box should be off-screen and unpainted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_percent_centers_absolute_box() {
|
||||
// The canonical centering idiom: an absolutely-positioned box at
|
||||
// top:50%/left:50% of its containing block pulled back by
|
||||
// translate(-50%,-50%) of its own size centers within it. In a 200x200
|
||||
// container a 40x40 box centers at (100,100), so its border box (with
|
||||
// top-left at 100,100 before the transform) becomes (80..120, 80..120).
|
||||
let tree = parse_html(
|
||||
"<html><body style=\"margin:0\">\
|
||||
<div style=\"position:relative; width:200px; height:200px\">\
|
||||
<div style=\"position:absolute; top:50%; left:50%; width:40px; height:40px; \
|
||||
background:#ff0000; transform:translate(-50%,-50%)\"></div>\
|
||||
</div>\
|
||||
</body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let center = pixmap.pixel(100, 100).expect("pixel");
|
||||
assert!(center.red() > 200 && center.blue() < 60, "expected centered red box, got {:?}", center);
|
||||
// Just outside the centered box stays white.
|
||||
let outside = pixmap.pixel(70, 70).expect("pixel");
|
||||
assert_eq!((outside.red(), outside.green(), outside.blue()), (255, 255, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_text_color() {
|
||||
let tree = parse_html(
|
||||
@@ -1065,4 +1514,80 @@ mod tests {
|
||||
}
|
||||
assert!(found_green, "expected green text to be painted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_inline_svg_subtree() {
|
||||
// A sprite-style svg: a <use> that references a <symbol> in the same
|
||||
// document must survive serialization so resvg can resolve it.
|
||||
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 out = serialize_svg(&tree, svg);
|
||||
assert!(out.starts_with("<svg"), "root svg tag: {out}");
|
||||
assert!(out.contains(r#"viewBox="0 0 10 10""#), "viewBox preserved: {out}");
|
||||
assert!(out.contains(r#"xmlns="http://www.w3.org/2000/svg""#), "xmlns injected: {out}");
|
||||
assert!(out.contains("<use") && out.contains(r##"href="#a""##), "use + href: {out}");
|
||||
assert!(out.contains("<symbol") && out.contains(r#"id="a""#), "symbol id: {out}");
|
||||
assert!(out.contains("<path") && out.contains("</path>"), "path opened + closed: {out}");
|
||||
assert!(out.trim_end().ends_with("</svg>"), "root closed: {out}");
|
||||
// The serialized string parses as a standalone SVG document.
|
||||
let opts = usvg::Options::default();
|
||||
assert!(
|
||||
usvg::Tree::from_data(out.as_bytes(), &opts).is_ok(),
|
||||
"usvg should parse serialized svg: {out}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_xmlns_only_when_absent() {
|
||||
let tree = parse_html(
|
||||
r#"<html><body><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4"><rect width="4" height="4"/></svg></body></html>"#,
|
||||
);
|
||||
let svg = tree.query_selector("svg").unwrap().unwrap();
|
||||
let out = serialize_svg(&tree, svg);
|
||||
assert_eq!(out.matches("xmlns=").count(), 1, "no duplicate xmlns: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_inline_svg() {
|
||||
// The <rect> inside an inline svg must rasterize (it is not an <img>).
|
||||
let tree = parse_html(
|
||||
r##"<html><body><svg width="40" height="40" viewBox="0 0 40 40"><rect x="0" y="0" width="40" height="40" fill="#ff0000"/></svg></body></html>"##,
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let mut found_red = false;
|
||||
'outer: for y in 0..80 {
|
||||
for x in 0..80 {
|
||||
let p = pixmap.pixel(x, y).expect("pixel");
|
||||
if p.red() > 200 && p.green() < 60 && p.blue() < 60 {
|
||||
found_red = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(found_red, "expected inline svg <rect> to paint red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_inline_svg_use_reference() {
|
||||
// The icon-sprite pattern: <use href="#id"> resolves against a <defs>
|
||||
// element in the same svg only because the whole subtree is serialized
|
||||
// and handed to resvg as one document.
|
||||
let tree = parse_html(
|
||||
r##"<html><body><svg width="40" height="40" viewBox="0 0 40 40"><defs><rect id="a" width="40" height="40" fill="#0000ff"/></defs><use href="#a"/></svg></body></html>"##,
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let mut found_blue = false;
|
||||
'outer: for y in 0..80 {
|
||||
for x in 0..80 {
|
||||
let p = pixmap.pixel(x, y).expect("pixel");
|
||||
if p.blue() > 200 && p.red() < 60 && p.green() < 60 {
|
||||
found_blue = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(found_blue, "expected <use> to instantiate the referenced <rect>");
|
||||
}
|
||||
}
|
||||
@@ -395,6 +395,16 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
"object-fit" => {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"fill" => style.object_fit = crate::ObjectFit::Fill,
|
||||
"contain" => style.object_fit = crate::ObjectFit::Contain,
|
||||
"cover" => style.object_fit = crate::ObjectFit::Cover,
|
||||
"scale-down" => style.object_fit = crate::ObjectFit::ScaleDown,
|
||||
"none" => style.object_fit = crate::ObjectFit::None,
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
"top" => style.inset[0] = inset_dim(value),
|
||||
"right" => style.inset[1] = inset_dim(value),
|
||||
"bottom" => style.inset[2] = inset_dim(value),
|
||||
@@ -499,10 +509,125 @@ 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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the subset of `transform` obscura applies at paint time: `translate`,
|
||||
/// `translateX`, `translateY` (px and %, stored unresolved so % can resolve
|
||||
/// against the element's own border box later) and `scale`/`scaleX`/`scaleY`.
|
||||
/// `rotate`, `skew`, `matrix`, and `perspective` are ignored (left unhandled)
|
||||
/// rather than erroring, so a value that mixes them still contributes its
|
||||
/// translate and scale parts.
|
||||
fn parse_transform(style: &mut LayoutStyle, value: &str) {
|
||||
let v = value.trim();
|
||||
if v.is_empty() || v.eq_ignore_ascii_case("none") {
|
||||
return;
|
||||
}
|
||||
let zero = crate::Dimension::Px(0.0);
|
||||
let (mut tx, mut ty): (Option<crate::Dimension>, Option<crate::Dimension>) = (None, None);
|
||||
let (mut sx, mut sy): (Option<f32>, Option<f32>) = (None, None);
|
||||
for (func, args) in transform_functions(v) {
|
||||
let parts: Vec<&str> = args.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
match func.to_ascii_lowercase().as_str() {
|
||||
"translate" => {
|
||||
if let Some(a) = parts.first() {
|
||||
tx = Some(dimension_value(a));
|
||||
}
|
||||
// A missing second component is 0 (translateY), not inherited.
|
||||
ty = Some(parts.get(1).map(|a| dimension_value(a)).unwrap_or(zero));
|
||||
}
|
||||
"translatex" => {
|
||||
if let Some(a) = parts.first() {
|
||||
tx = Some(dimension_value(a));
|
||||
}
|
||||
}
|
||||
"translatey" => {
|
||||
if let Some(a) = parts.first() {
|
||||
ty = Some(dimension_value(a));
|
||||
}
|
||||
}
|
||||
"scale" => {
|
||||
if let Some(a) = parts.first().and_then(|s| scale_number(s)) {
|
||||
// scale(s) is uniform; scale(sx, sy) sets each axis.
|
||||
sy = Some(parts.get(1).and_then(|s| scale_number(s)).unwrap_or(a));
|
||||
sx = Some(a);
|
||||
}
|
||||
}
|
||||
"scalex" => {
|
||||
if let Some(a) = parts.first().and_then(|s| scale_number(s)) {
|
||||
sx = Some(a);
|
||||
}
|
||||
}
|
||||
"scaley" => {
|
||||
if let Some(a) = parts.first().and_then(|s| scale_number(s)) {
|
||||
sy = Some(a);
|
||||
}
|
||||
}
|
||||
// rotate / skew / matrix / perspective: not modeled, skip.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if tx.is_some() || ty.is_some() {
|
||||
style.transform_translate = Some((tx.unwrap_or(zero), ty.unwrap_or(zero)));
|
||||
}
|
||||
if sx.is_some() || sy.is_some() {
|
||||
style.transform_scale = Some((sx.unwrap_or(1.0), sy.unwrap_or(1.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a `transform` value into its `name(args)` functions, in source order.
|
||||
/// Tokenizes on parentheses (tracking depth so a nested `calc(...)` inside an
|
||||
/// argument stays intact); the function name is the trailing identifier run
|
||||
/// just before each `(`.
|
||||
fn transform_functions(value: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = value;
|
||||
while let Some(open) = rest.find('(') {
|
||||
let name: String = rest[..open]
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
let after = &rest[open + 1..];
|
||||
let mut depth = 1i32;
|
||||
let mut end = None;
|
||||
for (i, c) in after.char_indices() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(e) = end else { break };
|
||||
if !name.is_empty() {
|
||||
out.push((name, after[..e].to_string()));
|
||||
}
|
||||
rest = &after[e + 1..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a unitless scale factor. A trailing `%` (`scale(50%)`) is accepted and
|
||||
/// divided by 100, matching the individual `scale` property's percentage form.
|
||||
fn scale_number(s: &str) -> Option<f32> {
|
||||
let t = s.trim();
|
||||
if let Some(p) = t.strip_suffix('%') {
|
||||
return p.trim().parse::<f32>().ok().map(|v| v / 100.0);
|
||||
}
|
||||
t.parse::<f32>().ok()
|
||||
}
|
||||
|
||||
/// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem
|
||||
/// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the
|
||||
/// parentheses in `minmax(...)` / `fit-content(...)`.
|
||||
|
||||
Reference in new issue
Block a user