use super::*;
const BASIC: &[u8] = include_bytes!("../tests/fixtures/basic-shapes.svg");
const PATHS: &[u8] = include_bytes!("../tests/fixtures/paths-gradients.svg");
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
img.data[img.idx(x, y)]
}
fn near(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
(0..4).all(|i| (a[i] - b[i]).abs() <= tol)
}
#[test]
fn gradient_stop_lookup_handles_duplicate_offsets() {
use crate::raster::eval_stops;
let stops = [(0.0, [0.0, 0.0, 0.0, 1.0]), (0.5, [1.0, 0.0, 0.0, 1.0]), (0.5, [0.0, 1.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])];
assert_eq!(eval_stops(&stops, 0.5), [1.0, 0.0, 0.0, 1.0]);
assert!(near(eval_stops(&stops, 0.5001), [0.0, 1.0, 0.0, 1.0], 3e-4));
assert_eq!(eval_stops(&stops, 0.75), [0.0, 0.5, 0.5, 1.0]);
}
#[test]
fn raster_surface_budget_rejects_oversized_destination_before_allocation() {
let doc = parse(br#""#).unwrap();
// 16,384² RGBA-f32 pixels exceed the bounded destination-surface budget; no giant
// allocation should be attempted and the caller receives an empty image.
let img = rasterize(&doc, 16_384, 16_384, 1.0);
assert!(img.data.is_empty());
assert_eq!((img.width, img.height), (0, 0));
}
#[test]
fn basic_shapes_structure() {
let d = parse(BASIC).unwrap();
assert_eq!((d.width, d.height), (200.0, 100.0));
assert_eq!(d.pixel_size(), (200, 100));
// viewBox 400×200 into 200×100.
assert!((d.root.transform.as_coeffs()[0] - 0.5).abs() < 1e-12);
let shapes = d.flatten();
assert_eq!(shapes.len(), 6);
let (m, o, s) = shapes[1];
assert_eq!(s.name, "box");
assert!(matches!(s.geom, Geom::Rect { w, h, rx, .. } if w == 120.0 && h == 80.0 && rx == 12.0));
assert!((o - 0.8).abs() < 1e-12);
// translate(20 20) then the viewBox scale.
let p = m * kurbo::Point::new(10.0, 10.0);
assert!((p.x - 15.0).abs() < 1e-9 && (p.y - 15.0).abs() < 1e-9);
let st = s.stroke.as_ref().unwrap();
assert_eq!(st.width, 6.0);
assert_eq!(st.paint, Paint::Color([1.0, 1.0, 1.0]));
assert!(matches!(shapes[2].2.geom, Geom::Ellipse { rx, ry, .. } if rx == 30.0 && ry == 30.0));
assert_eq!(shapes[3].2.fill_opacity, 0.75);
assert!(shapes[4].2.fill.is_none());
assert_eq!(shapes[4].2.stroke.as_ref().unwrap().join, Join::Round);
assert_eq!(shapes[5].2.stroke.as_ref().unwrap().cap, Cap::Round);
}
#[test]
fn basic_shapes_raster() {
let d = parse(BASIC).unwrap();
let img = rasterize(&d, 200, 100, 1.0);
let bg = [0x20 as f32 / 255.0, 0x28 as f32 / 255.0, 0x30 as f32 / 255.0, 1.0];
assert!(near(px(&img, 2, 2), bg, 1e-3));
// Inside the box: tomato at group opacity 0.8 over the background.
let tomato = [1.0, 99.0 / 255.0, 71.0 / 255.0];
let want = [0, 1, 2].map(|i| tomato[i] * 0.8 + bg[i] * 0.2);
let got = px(&img, 45, 35);
assert!(near(got, [want[0], want[1], want[2], 1.0], 2e-3), "{got:?} vs {want:?}");
// Scaled ×2: the same point lands at double the coordinates, edges stay sharp.
let big = rasterize(&d, 400, 200, 2.0);
assert!(near(px(&big, 90, 70), got, 2e-3));
}
#[test]
fn paths_gradients_css_use() {
let d = parse(PATHS).unwrap();
assert_eq!(d.pixel_size(), (160, 120));
let shapes = d.flatten();
assert_eq!(shapes.len(), 6, "{:?}", shapes.iter().map(|s| &s.2.name).collect::>());
// CSS class → gradient fill.
let Some(Paint::Gradient(g)) = &shapes[0].2.fill else { panic!("gradient expected") };
assert!(g.bbox_units);
assert_eq!(g.stops.len(), 2);
assert_eq!(shapes[1].2.fill_rule, FillRule::EvenOdd);
// #wave: id selector beats nothing; stroke from CSS.
assert!(shapes[2].2.fill.is_none());
assert_eq!(shapes[2].2.stroke.as_ref().unwrap().width, 5.0);
// use → the referenced path with the use's fill, translated.
assert_eq!(shapes[4].2.fill, Some(Paint::Color([128.0 / 255.0, 0.0, 128.0 / 255.0])));
let p = shapes[4].0 * kurbo::Point::new(0.0, 0.0);
assert!((p.x - 100.0).abs() < 1e-9 && (p.y - 110.0).abs() < 1e-9);
assert_eq!(shapes[5].2.stroke.as_ref().unwrap().dash, Some((vec![4.0, 2.0], 0.0)));
let img = rasterize(&d, 160, 120, 1.0);
// Vertical gradient: yellow at the top, red at the bottom.
let top = px(&img, 40, 11);
let bottom = px(&img, 40, 48);
assert!(top[1] > 0.7 && bottom[1] < 0.3, "{top:?} {bottom:?}");
// Even-odd hole.
assert_eq!(px(&img, 120, 35)[3], 0.0);
assert!(px(&img, 95, 15)[3] > 0.99);
// Radial gradient: white centre.
let c = px(&img, 40, 90);
assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}");
}
#[test]
fn root_svg_percentages_use_the_document_viewport() {
for (attrs, width, height) in [
("width=\"100\" height=\"80\"", 100.0, 80.0),
("width=\"50%\" height=\"200%\"", 150.0, 300.0),
("", 300.0, 150.0),
("width=\"100\" height=\"80\" viewBox=\"0 0 200 160\"", 200.0, 160.0),
("width=\"NaN%\" height=\"1e400\"", 300.0, 150.0),
] {
let svg = format!(r#""#);
let d = parse(svg.as_bytes()).unwrap();
let shapes = d.flatten();
assert_eq!(shapes.len(), 1);
assert!(
matches!(shapes[0].2.geom, Geom::Rect { x, y, w, h, .. }
if x == width / 4.0 && y == height / 4.0 && w == width / 2.0 && h == height / 2.0),
"{attrs}: {:?}",
shapes[0].2.geom
);
}
}
#[test]
fn nested_svg_percentages_use_the_nearest_viewport() {
let d = parse(
br#""#,
)
.unwrap();
let shapes = d.flatten();
assert_eq!(shapes.len(), 4);
for ((_, _, shape), (name, width, height)) in
shapes.iter().zip([("inner", 20.0, 10.0), ("deep", 10.0, 5.0), ("outer", 50.0, 10.0), ("viewbox", 100.0, 50.0)])
{
assert_eq!(shape.name, name);
assert!(matches!(shape.geom, Geom::Rect { w, h, .. } if w == width && h == height), "{name}: {:?}", shape.geom);
}
let img = rasterize(&d, 100, 80, 1.0);
assert_eq!(px(&img, 15, 15), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&img, 35, 12), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&img, 35, 17)[3], 0.0);
assert_eq!(px(&img, 45, 65)[3], 1.0);
assert_eq!(px(&img, 55, 65)[3], 0.0);
}
#[test]
fn non_finite_viewboxes_do_not_panic_or_produce_nan_geometry() {
for attrs in ["viewBox=\"0 0 1e400 100\"", "viewBox=\"0 0 100 1e400\"", "viewBox=\"-1e400 0 100 100\""] {
for shape in ["", ""] {
let svg = format!(r#"{shape}"#);
let d = parse(svg.as_bytes()).unwrap();
assert_eq!((d.width, d.height), (300.0, 150.0));
let shapes = d.flatten();
assert_eq!(shapes.len(), 1);
assert!(matches!(shapes[0].2.geom, Geom::Rect { x, y, w, h, rx, ry }
if [x, y, w, h, rx, ry].iter().all(|v| v.is_finite()) && w == 150.0 && h == 75.0));
assert!(shapes[0].0.as_coeffs().iter().all(|v| v.is_finite()));
}
}
}
#[test]
fn non_finite_nested_viewports_do_not_panic_or_produce_nan_geometry() {
for attrs in ["width=\"1e400\" height=\"10\"", "width=\"10\" height=\"1e400\""] {
for shape in ["", ""] {
let svg = format!(r#"{shape}"#);
let d = parse(svg.as_bytes()).unwrap();
let shapes = d.flatten();
assert_eq!(shapes.len(), 1);
assert!(matches!(shapes[0].2.geom, Geom::Rect { x, y, w, h, rx, ry }
if [x, y, w, h, rx, ry].iter().all(|v| v.is_finite()) && w > 0.0 && h > 0.0));
assert!(shapes[0].0.as_coeffs().iter().all(|v| v.is_finite()));
}
}
}
#[test]
fn zero_size_nested_viewport_draws_nothing() {
for attrs in ["width=\"0\" height=\"10\"", "width=\"10\" height=\"0\""] {
let svg = format!(r#""#);
assert!(parse(svg.as_bytes()).unwrap().flatten().is_empty(), "{attrs}");
}
}
#[test]
fn malformed_input() {
assert!(parse(b"").is_err());
assert!(parse(b"not xml").is_err());
let d = parse(b"hi").unwrap();
assert_eq!(d.pixel_size(), (300, 150));
assert_eq!(d.skipped, vec!["text".to_string()]);
assert!(looks_like_svg(BASIC));
assert!(!looks_like_svg(b"\x89PNG"));
}
#[test]
fn transforms() {
let m = parse_transform("translate(10,20) rotate(90) scale(2 3)");
let p = m * kurbo::Point::new(1.0, 1.0);
assert!((p.x - 7.0).abs() < 1e-9 && (p.y - 22.0).abs() < 1e-9, "{p:?}");
let m = parse_transform("matrix(1 0 0 1 5 6) skewX(45)");
let p = m * kurbo::Point::new(0.0, 1.0);
assert!((p.x - 6.0).abs() < 1e-9 && (p.y - 7.0).abs() < 1e-9);
}
#[test]
fn nested_entities_are_refused_instead_of_expanding_without_bound() {
// #2129: each top-level `&b;` stays under roxmltree's per-reference cap but adds 12 500
// nodes, so 40 KB of references expands to 5 million.
let dtd = format!("", "".repeat(50), "&a;".repeat(250));
let bomb = format!("{}", "&b;".repeat(400));
assert!(matches!(crate::parse(bomb.as_bytes()), Err(crate::Error::Xml(_))));
// An ordinary entity, as Illustrator declares them, still parses.
let ok = "]>";
assert!(crate::parse(ok.as_bytes()).is_ok());
}
#[test]
fn deep_nesting_errors_instead_of_overflowing_the_stack() {
// 100 000 nested groups overflowed the XML parser's stack and aborted the process.
let deep = |n: usize| format!("{}{}", "".repeat(n), "".repeat(n));
assert!(matches!(crate::parse(deep(100_000).as_bytes()), Err(crate::Error::Xml(_))));
assert!(crate::parse(deep(100).as_bytes()).is_ok());
// Comments, CDATA, quoted '>' and self-closing tags don't count.
let s = format!(
"b\">{}",
"".repeat(1000),
"".repeat(1000),
"".repeat(1000)
);
assert_eq!(crate::xml_depth(&s), 2);
assert!(crate::parse(s.as_bytes()).is_ok());
}
/// How deep `g`'s groups nest, and how many shapes it holds.
fn depth_and_shapes(g: &Group) -> (usize, usize) {
let mut depth = 0;
let mut shapes = 0;
for c in &g.children {
match c {
Node::Group(sub) => {
let (d, s) = depth_and_shapes(sub);
depth = depth.max(d + 1);
shapes += s;
}
Node::Shape(_) => shapes += 1,
Node::Image(_) => {}
}
}
(depth, shapes)
}
#[test]
fn branching_use_references_stop_at_the_expansion_budget() {
// Each group uses the one before twice: 30 levels are 2^30 copies of one rectangle, from a
// file of a few kilobytes (#1236).
let mut defs = String::from(r#""#);
for i in 1..=30 {
defs += &format!(r##""##, p = i - 1);
}
let svg = format!(r##"{defs}"##);
let t0 = std::time::Instant::now();
let e = parse(svg.as_bytes()).unwrap_err();
assert!(e.to_string().contains("expand to more than"), "{e}");
assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed());
// Below the budget the same pattern imports whole: 2^10 rectangles.
let small = svg.replace(r##""##, r##""##);
let doc = parse(small.as_bytes()).unwrap();
assert_eq!(depth_and_shapes(&doc.root).1, 1024);
}
#[test]
fn mutually_referencing_uses_are_cut_at_the_first_repeat() {
// A uses B and B uses A, neither inside the other: once each, not down to the depth limit.
let svg = r##"
"##;
let doc = parse(svg.as_bytes()).unwrap();
let (depth, shapes) = depth_and_shapes(&doc.root);
assert_eq!(shapes, 2, "a's and b's rectangle, once each");
assert!(depth <= 6, "{depth}");
}
#[test]
fn a_symbol_used_a_thousand_times_still_imports() {
let uses: String = (0..1000).map(|i| format!(r##""##)).collect();
let svg = format!(
r##"{uses}"##
);
let doc = parse(svg.as_bytes()).unwrap();
assert_eq!(depth_and_shapes(&doc.root).1, 3000);
}
/// An SVG with one `{rects}"#).into_bytes()
}
#[test]
fn comma_selector_lists_share_one_declaration_block() {
// Before #2140 every selector of a comma list got its own copy of the declaration block, so
// `a,a,a…{…}` stored selectors × declarations strings; one selector past the cap is refused.
let sels = vec!["rect"; MAX_CSS_SELECTORS + 1].join(",");
let decls: String = (0..10).map(|i| format!("p{i}:v;")).collect();
let svg = styled(&format!("{sels}{{fill:red;{decls}}}"), &[""]);
let e = parse(&svg).unwrap_err();
assert!(e.to_string().contains("selectors"), "{e}");
}
#[test]
fn a_style_sheet_with_too_many_declarations_errors() {
let decls: String = (0..=MAX_CSS_DECLARATIONS).map(|i| format!("p{i}:v;")).collect();
let svg = styled(&format!("rect{{{decls}}}"), &[""]);
let e = parse(&svg).unwrap_err();
assert!(e.to_string().contains("declarations"), "{e}");
}
#[test]
fn selector_lists_keep_the_css_cascade() {
let green = Some(Paint::Color([0.0, 1.0, 0.0]));
let blue = Some(Paint::Color([0.0, 0.0, 1.0]));
let red = Some(Paint::Color([1.0, 0.0, 0.0]));
let css = "rect, .b, #c { fill: #00ff00 } .b, .d { fill: #0000ff; stroke: #000000; stroke-width: 3 } rect.e, #c { fill: #ff0000 }";
let svg = styled(css, &["", r#"class="b""#, r#"id="c" class="b""#, r#"class="b" style="fill:#00ff00""#, r##"class="e" fill="#0000ff""##, r#"class="d""#]);
let doc = parse(&svg).unwrap();
let shapes = doc.flatten();
let fills: Vec<_> = shapes.iter().map(|s| s.2.fill.clone()).collect();
// Type selector; class beats type; id beats class; inline style beats every rule; a later,
// more specific rule beats the presentation attribute.
assert_eq!(fills, vec![green.clone(), blue.clone(), red.clone(), green, red, blue]);
assert!(shapes[0].2.stroke.is_none());
// The second list's block reaches both of its selectors.
for i in [1, 5] {
assert_eq!(shapes[i].2.stroke.as_ref().map(|s| s.width), Some(3.0), "shape {i}");
}
}
#[test]
fn a_comma_list_stores_its_declarations_once() {
let sels = vec!["rect"; 1000].join(",");
let decls: String = (0..50).map(|i| format!("p{i}:v;")).collect();
let mut sheet = StyleSheet::default();
parse_css(&format!("{sels}{{{decls}}}"), &mut sheet).unwrap();
assert_eq!(sheet.rules.len(), 1000);
assert_eq!(sheet.blocks.len(), 1);
assert_eq!(sheet.decls, 50);
}
#[test]
fn rules_of_unsupported_selectors_store_no_blocks() {
let mut sheet = StyleSheet::default();
parse_css(&"a b{}".repeat(100_000), &mut sheet).unwrap();
parse_css("a b{fill:red} a>b, rect{fill:blue} c:hover, d{fill:red}", &mut sheet).unwrap();
assert_eq!(sheet.rules.len(), 2);
assert_eq!(sheet.blocks.len(), 2);
assert!(sheet.rules.iter().all(|r| sheet.blocks.get(r.block).is_some_and(|b| b.len() == 1)));
}
#[test]
fn huge_class_lists_count_against_the_matching_budget() {
// One rule, but a 1 MB class attribute re-split on each of 100 `