#![cfg(all(feature = "skia-native", unix))]
use std::path::{Path, PathBuf};
use velox_dom::{VNode, h, layout::compute_layout};
use velox_renderer::{image_decode_count, render_vnode_to_rgba, reset_image_decode_count};
use velox_style::Stylesheet;
const FIELD: [u8; 4] = [51, 102, 204, 255];
const DISC: [u8; 4] = [255, 204, 0, 255];
fn two_tone_svg(w: u32, h: u32) -> String {
format!(
concat!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">"##,
r##"<rect x="0" y="0" width="{w}" height="{h}" fill="#3366cc"/>"##,
r##"<circle cx="{cx}" cy="{cy}" r="{r}" fill="#ffcc00"/>"##,
"</svg>"
),
w = w,
h = h,
cx = f64::from(w) / 2.0,
cy = f64::from(h) / 2.0,
r = (f64::from(w.min(h)) / 4.0).max(1.0),
)
}
fn write_png(path: &Path, w: i32, h: i32) {
use skia_safe as sk;
let mut surface = sk::surfaces::raster_n32_premul((w, h)).expect("png fixture surface");
surface
.canvas()
.clear(sk::Color::from_argb(255, 51, 102, 204));
let image = surface.image_snapshot();
#[allow(deprecated)]
let data = image
.encode_to_data(skia_safe::EncodedImageFormat::PNG)
.expect("encode png fixture");
std::fs::write(path, data.as_bytes()).expect("write png fixture");
}
fn fixture_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!("velox-image-render-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create fixture dir");
dir
}
fn write_fixture(name: &str, contents: &[u8]) -> String {
let path = fixture_dir().join(name);
std::fs::write(&path, contents).expect("write fixture");
path.to_string_lossy().into_owned()
}
fn px(rgba: &[u8], w: i32, x: i32, y: i32) -> [u8; 4] {
let i = ((y * w + x) * 4) as usize;
rgba[i..i + 4].try_into().expect("four bytes per pixel")
}
fn all_transparent(rgba: &[u8]) -> bool {
rgba.chunks_exact(4).all(|p| p == [0, 0, 0, 0])
}
fn ink_bbox(rgba: &[u8], w: i32, h: i32) -> Option<(i32, i32, i32, i32)> {
let mut acc: Option<(i32, i32, i32, i32)> = None;
for y in 0..h {
for x in 0..w {
if px(rgba, w, x, y)[3] == 0 {
continue;
}
acc = Some(match acc {
None => (x, y, x, y),
Some((l, t, r, b)) => (l.min(x), t.min(y), r.max(x), b.max(y)),
});
}
}
acc
}
fn render(vnode: &VNode, w: i32, h: i32) -> Vec<u8> {
render_vnode_to_rgba(vnode, &Stylesheet::default(), w, h).expect("render to rgba")
}
fn img_rect(vnode: &VNode, w: i32, h: i32, n: usize) -> (i32, i32, i32, i32) {
let rect = compute_layout(vnode, w, h).children[n].rect;
(rect.x, rect.y, rect.w, rect.h)
}
#[test]
fn an_svg_rasterises_and_draws_into_the_elements_rect() {
let src = write_fixture("one-svg.svg", two_tone_svg(40, 24).as_bytes());
let vnode = h(
"div",
vec![("style", "width:120px;height:80px")],
vec![h(
"img",
vec![("src", src.as_str()), ("style", "width:100px;height:60px")],
vec![],
)],
);
let rgba = render(&vnode, 120, 80);
assert_eq!(
img_rect(&vnode, 120, 80, 0),
(0, 0, 100, 60),
"precondition: layout puts the image in the declared 100x60 rect, since \
the pixel assertions below are about that box and no other"
);
assert_eq!(
ink_bbox(&rgba, 120, 80),
Some((0, 0, 99, 59)),
"the SVG must fill the element's declared rect and stop there, so the \
declared size is what positioned and sized the draw"
);
assert_eq!(
px(&rgba, 120, 1, 1),
FIELD,
"the rect's interior carries the SVG's own field colour, so what was \
drawn is a raster of THIS file and not a placeholder"
);
assert_eq!(
px(&rgba, 120, 50, 30),
DISC,
"the centre of the rect carries the disc, so the vector geometry was \
rasterised rather than the file being drawn as a flat fill"
);
}
#[test]
fn one_svg_at_two_declared_sizes_fills_both_rects() {
let src = write_fixture("two-sizes-svg.svg", two_tone_svg(37, 19).as_bytes());
let small = |style: &str| h("img", vec![("src", src.as_str()), ("style", style)], vec![]);
let vnode = h(
"div",
vec![("style", "width:120px;height:100px")],
vec![
small("display:block;width:20px;height:10px"),
small("display:block;width:60px;height:40px"),
],
);
let rgba = render(&vnode, 120, 100);
let first = (0, 0, 20, 10);
let second = (0, 10, 60, 40);
for (name, (x, y, w, h)) in [("first", first), ("second", second)] {
assert_eq!(
(
px(&rgba, 120, x + 1, y + 1),
px(&rgba, 120, x + w / 2, y + h / 2)
),
(FIELD, DISC),
"{name} rect {first:?} must carry the SVG's field colour in its \
interior and its disc at its own centre, at whatever size it was \
declared — the cached raster is at the SOURCE's 37x19 and Skia \
scales it per draw"
);
}
assert_eq!(
ink_bbox(&rgba, 120, 100),
Some((0, 0, 59, 49)),
"the larger rect is 60x40 and starts at y=10, so the union of both \
draws is exactly that — a second draw at the first rect's size would \
leave the bottom 10 rows empty, and one draw at the larger size would \
overflow the first"
);
assert_eq!(
image_decode_count(),
1,
"one file, one raster: the two draws share a single decode. This is the \
assertion that would fail if the cache were rebuilt per draw."
);
}
#[test]
fn a_png_still_decodes_through_the_unchanged_raster_path() {
let path = fixture_dir().join("plain.png");
write_png(&path, 16, 16);
let src = path.to_string_lossy().into_owned();
let vnode = h(
"div",
vec![("style", "width:40px;height:40px")],
vec![h(
"img",
vec![("src", src.as_str()), ("style", "width:32px;height:32px")],
vec![],
)],
);
reset_image_decode_count();
let rgba = render(&vnode, 40, 40);
assert_eq!(
ink_bbox(&rgba, 40, 40),
Some((0, 0, 31, 31)),
"precondition: the PNG filled its declared 32x32 rect"
);
assert_eq!(
px(&rgba, 40, 16, 16),
FIELD,
"a PNG must still decode to its own pixels — a branch ordered ahead of \
`Image::from_encoded`, or a format dispatch that sniffed the wrong \
thing, would draw nothing at all here"
);
assert_eq!(
image_decode_count(),
1,
"and through exactly one decode, by the same route as an SVG"
);
}
#[test]
fn an_unsized_img_takes_its_intrinsic_size_from_the_probe() {
let src = write_fixture("unsized-svg.svg", two_tone_svg(37, 19).as_bytes());
let vnode = h(
"div",
vec![("style", "width:80px;height:60px")],
vec![h("img", vec![("src", src.as_str())], vec![])],
);
reset_image_decode_count();
let rgba = render(&vnode, 80, 60);
assert_eq!(
img_rect(&vnode, 80, 60, 0),
(0, 0, 37, 19),
"an unsized `<img>` is a replaced element sized from its source's \
intrinsic size (CSS 2.1 §10.3.2 step 2). With no probe registered \
`velox-dom` has no way to know the source's size and lays it out at \
0x0, which paints nothing — so this asserts the REGISTRATION too."
);
assert_eq!(
ink_bbox(&rgba, 80, 60),
Some((0, 0, 36, 18)),
"and the paint agrees with that rect: the intrinsic size is not just \
in the layout, it is what the image was drawn into"
);
assert_eq!(px(&rgba, 80, 18, 9), DISC, "with the disc at its centre");
assert_eq!(
image_decode_count(),
1,
"layout asks the probe for a width pass and again for a height pass, \
and the paint walk asks again -- four questions about one src, one \
decode. A probe that re-read the file per question, or a cache the \
probe did not share with the painter, would report 4."
);
}
#[test]
fn a_missing_or_garbage_src_renders_nothing_and_does_not_panic() {
let cases: Vec<(&str, String)> = vec![
(
"a path that does not exist",
fixture_dir()
.join("does-not-exist.png")
.to_string_lossy()
.into_owned(),
),
("an empty file", write_fixture("empty.svg", b"")),
(
"random bytes wearing a .png extension",
write_fixture("garbage.png", &[0xde, 0xad, 0xbe, 0xef, 0x00, 0x11, 0x22]),
),
(
"text that IS detected as SVG and then fails to parse",
write_fixture(
"malformed.svg",
b"<svg width=\"10\" height=\"10\"><rect <//></svg>",
),
),
("an https: URL", "https://example.invalid/logo.svg".to_string()),
(
"a data: URL carrying a real SVG",
"data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%2210%22%3E%3C%2Fsvg%3E"
.to_string(),
),
];
for (what, src) in cases {
let vnode = h(
"div",
vec![("style", "width:60px;height:40px;background:#ff0000")],
vec![h("img", vec![("src", src.as_str())], vec![])],
);
let rgba = render(&vnode, 60, 40);
assert!(
!all_transparent(&rgba),
"{what}: the container's own background must still paint, or this \
test proves nothing about the image"
);
for y in 0..40 {
for x in 0..60 {
assert_eq!(
px(&rgba, 60, x, y),
[255, 0, 0, 255],
"{what}: pixel ({x}, {y}) is not the container's own \
background, so an unresolvable src drew something"
);
}
}
}
}
#[test]
fn two_draws_of_one_src_decode_once_per_frame_and_not_once_per_frame() {
let src = write_fixture("counted.svg", two_tone_svg(30, 30).as_bytes());
let img = || {
h(
"img",
vec![
("src", src.as_str()),
("style", "display:block;width:30px;height:30px"),
],
vec![],
)
};
let vnode = h(
"div",
vec![("style", "width:60px;height:60px")],
vec![img(), img()],
);
reset_image_decode_count();
render(&vnode, 60, 60);
assert_eq!(
image_decode_count(),
1,
"two draws of one src in ONE frame must decode once"
);
reset_image_decode_count();
render(&vnode, 60, 60);
render(&vnode, 60, 60);
assert_eq!(
image_decode_count(),
0,
"two further frames of the same tree decode NOTHING. This is the whole \
point of hoisting the cache: a visible image used to cost a disk read \
and a full decode on every single frame."
);
}
#[test]
fn an_svg_the_cache_cannot_size_degrades_exactly_as_a_png_does() {
let cases = [
(
"a zero-sized SVG",
write_fixture("zero-size.svg", two_tone_svg(0, 0).as_bytes()),
),
(
"an SVG with nothing to draw",
write_fixture(
"empty-content.svg",
two_tone_svg(24, 24)
.replace("<circle", "<g")
.replace("</svg>", "</g></svg>")
.as_bytes(),
),
),
];
for (what, src) in cases {
let vnode = h(
"div",
vec![("style", "width:60px;height:40px")],
vec![h("img", vec![("src", src.as_str())], vec![])],
);
let rgba = render(&vnode, 60, 40);
assert!(
all_transparent(&rgba),
"{what}: degrades to painting NOTHING, which is what a PNG whose \
file is missing already does. What was NOT chosen, and why: \
velox-dom states at layout.rs that CSS 2.1 step 3 (the 300x150 \
default object size) is deliberately not modelled, and an \
unsized replaced element with an unresolvable source is a \
zero-size box there — the same as an empty inline-block. A \
fallback invented here would be a second answer to that question, \
in the wrong crate, where nothing could invalidate it."
);
let (_, _, w, h) = img_rect(&vnode, 60, 60, 0);
assert_eq!(
(w, h),
(0, 0),
"{what}: and it is a zero-SIZE box rather than an invented one, so \
`velox-dom` still has exactly one rule for an unsized replaced \
element with no answer to give. The zero-size box's own `y` is its \
baseline position on the line, which is why only the extents are \
asserted — a zero-height box at any `y` paints nothing."
);
}
}