use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use bob::Source;
use eframe::egui::{
self, Align2, Color32, ColorImage, CornerRadius, FontId, Id, Mesh, Pos2, Rect, Response, Sense,
TextureHandle, TextureOptions, Vec2,
};
use parking_lot::Mutex;
use pv::{PvState, Value};
use resvg::{tiny_skia, usvg};
use crate::screen::Node;
use crate::style::{color, named};
use crate::svg;
use crate::widgets::{Cx, placeholder, run_action};
const MAX_SIDE: f32 = 4096.0;
#[derive(Clone)]
enum Image {
Svg(Arc<usvg::Tree>),
Raster(Arc<ColorImage>),
}
impl Image {
fn size(&self) -> Vec2 {
match self {
Image::Svg(tree) => Vec2::new(tree.size().width(), tree.size().height()),
Image::Raster(img) => Vec2::new(img.size[0] as f32, img.size[1] as f32),
}
}
}
enum Entry {
Loading(Vec<egui::ViewportId>),
Ready(Image),
Failed,
}
struct Images {
entries: HashMap<Source, Entry>,
generation: u64,
}
static IMAGES: LazyLock<Mutex<Images>> = LazyLock::new(|| {
Mutex::new(Images {
entries: HashMap::new(),
generation: 0,
})
});
#[cfg(not(target_arch = "wasm32"))]
const LOADERS: usize = 4;
#[cfg(not(target_arch = "wasm32"))]
static QUEUE: LazyLock<Mutex<std::sync::mpsc::Sender<Job>>> = LazyLock::new(|| {
let (tx, rx) = std::sync::mpsc::channel::<Job>();
let rx = Arc::new(Mutex::new(rx));
for _ in 0..LOADERS {
let rx = rx.clone();
let spawned = std::thread::Builder::new()
.name("image-loader".into())
.stack_size(16 * 1024 * 1024)
.spawn(move || {
loop {
let job = rx.lock().recv();
match job {
Ok(job) => load(job),
Err(_) => break,
}
}
});
if let Err(e) = spawned {
log::error!("cannot start an image loader: {e}");
}
}
Mutex::new(tx)
});
struct Job {
source: Source,
generation: u64,
ctx: egui::Context,
}
pub fn forget() {
let mut images = IMAGES.lock();
images.entries.clear();
images.generation += 1;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn loading() -> bool {
IMAGES
.lock()
.entries
.values()
.any(|e| matches!(e, Entry::Loading(_)))
}
fn image(ctx: &egui::Context, source: &Source) -> Option<Result<Image, ()>> {
let mut images = IMAGES.lock();
let viewport = ctx.viewport_id();
match images.entries.get_mut(source) {
Some(Entry::Ready(img)) => return Some(Ok(img.clone())),
Some(Entry::Failed) => return Some(Err(())),
Some(Entry::Loading(waiting)) => {
if !waiting.contains(&viewport) {
waiting.push(viewport);
}
return None;
}
None => {}
}
images
.entries
.insert(source.clone(), Entry::Loading(vec![viewport]));
let job = Job {
source: source.clone(),
generation: images.generation,
ctx: ctx.clone(),
};
drop(images);
#[cfg(not(target_arch = "wasm32"))]
let _ = QUEUE.lock().send(job);
#[cfg(target_arch = "wasm32")]
wasm_bindgen_futures::spawn_local(async move {
let data = crate::fetch::bytes_async(&job.source).await;
finish(job, data);
});
None
}
#[cfg(not(target_arch = "wasm32"))]
fn load(job: Job) {
let data = crate::fetch::bytes(&job.source);
finish(job, data);
}
fn finish(job: Job, data: Result<Vec<u8>, bob::Error>) {
let source = &job.source;
let data = data.map_err(|e| e.to_string());
let read = || match data {
Ok(data) => match decode(source, &data) {
Ok(img) => Entry::Ready(img),
Err(e) => {
log::warn!("cannot show {}: {e}", describe(source));
Entry::Failed
}
},
Err(e) => {
log::warn!("{e}");
Entry::Failed
}
};
let entry = std::panic::catch_unwind(read).unwrap_or(Entry::Failed);
let mut images = IMAGES.lock();
let mut waiting = Vec::new();
if images.generation == job.generation
&& let Some(Entry::Loading(w)) = images.entries.insert(job.source, entry)
{
waiting = w;
}
drop(images);
for viewport in waiting {
job.ctx.request_repaint_of(viewport);
}
}
fn describe(source: &Source) -> String {
match source {
Source::File(p) => p.display().to_string(),
Source::Url(u) => u.clone(),
}
}
fn decode(source: &Source, data: &[u8]) -> Result<Image, String> {
if data.starts_with(b"\x89PNG") {
return decode_png(data).map(|img| Image::Raster(Arc::new(img)));
}
let name = describe(source).to_ascii_lowercase();
let svg = name.ends_with(".svg")
|| name.ends_with(".svgz")
|| data.trim_ascii_start().starts_with(b"<");
if !svg {
return Err("only SVG and PNG images are supported".into());
}
svg::parse(data).map(|tree| Image::Svg(Arc::new(tree)))
}
const MAX_PNG_DECODER_BYTES: usize = 64 * 1024 * 1024;
fn decode_png(data: &[u8]) -> Result<ColorImage, String> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
decoder.set_transformations(png::Transformations::normalize_to_color8());
decoder.set_limits(png::Limits {
bytes: MAX_PNG_DECODER_BYTES,
});
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
let (width, height) = reader.info().size();
if !svg::size_ok(width, height) {
return Err(format!("PNG of {width}x{height} pixels is too large"));
}
let mut buf = vec![0; reader.output_buffer_size().ok_or("PNG too large")?];
let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
let buf = &buf[..info.buffer_size()];
let size = [info.width as usize, info.height as usize];
let rgba: Vec<u8> = match info.color_type {
png::ColorType::Rgba => buf.to_vec(),
png::ColorType::Rgb => buf
.as_chunks::<3>()
.0
.iter()
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect(),
png::ColorType::GrayscaleAlpha => buf
.as_chunks::<2>()
.0
.iter()
.flat_map(|p| [p[0], p[0], p[0], p[1]])
.collect(),
png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
png::ColorType::Indexed => return Err("unexpected indexed PNG".into()),
};
Ok(ColorImage::from_rgba_unmultiplied(size, &rgba))
}
#[derive(Default)]
pub struct State {
texture: Option<Texture>,
index: Option<i64>,
}
struct Texture {
key: (Source, [usize; 2]),
handle: TextureHandle,
}
fn texture<'a>(
ctx: &egui::Context,
slot: &'a mut Option<Texture>,
source: &Source,
img: &Image,
size: Vec2,
) -> Option<&'a TextureHandle> {
let max_side = ctx.input(|i| i.max_texture_side) as f32;
let px = match img {
Image::Raster(r) if r.size.into_iter().any(|n| n as f32 > max_side) => {
log::warn!("cannot show {}: too large for a texture", describe(source));
return None;
}
Image::Raster(r) => r.size,
Image::Svg(_) => svg_pixels(size * ctx.pixels_per_point(), MAX_SIDE.min(max_side)),
};
let key = (source.clone(), px);
if slot.as_ref().is_none_or(|t| t.key != key) {
let pixels = match img {
Image::Raster(r) => (**r).clone(),
Image::Svg(tree) => render_svg(tree, px),
};
let options = TextureOptions::LINEAR;
match slot {
Some(t) => {
t.handle.set(pixels, options);
t.key = key;
}
None => {
*slot = Some(Texture {
key,
handle: ctx.load_texture("bobview-symbol", pixels, options),
});
}
}
}
slot.as_ref().map(|t| &t.handle)
}
fn svg_pixels(size: Vec2, max_side: f32) -> [usize; 2] {
let size = size.max(Vec2::splat(1.0));
let longest = size.max_elem();
let step = (longest.log2() * 4.0).ceil() / 4.0;
let scale = (step.exp2() / longest).min(max_side / longest);
let px = (size * scale).round().max(Vec2::splat(1.0));
[px.x as usize, px.y as usize]
}
fn render_svg(tree: &usvg::Tree, [w, h]: [usize; 2]) -> ColorImage {
let Some(mut pixmap) = tiny_skia::Pixmap::new(w as u32, h as u32) else {
return ColorImage::filled([w, h], Color32::TRANSPARENT);
};
let size = tree.size();
let scale = tiny_skia::Transform::from_scale(w as f32 / size.width(), h as f32 / size.height());
resvg::render(tree, scale, &mut pixmap.as_mut());
ColorImage::from_rgba_premultiplied([w, h], pixmap.data())
}
fn fit(native: Vec2, room: Vec2) -> Vec2 {
if native.x <= 0.0 || native.y <= 0.0 {
return room;
}
let scale = (room.x / native.x).min(room.y / native.y);
native * scale
}
fn quad(mesh: &mut Mesh, center: Pos2, size: Vec2, angle: f32, tint: Color32, uv: Option<Rect>) {
let start = mesh.vertices.len();
let uv = if mesh.texture_id == egui::TextureId::default() {
Rect::from_min_max(egui::epaint::WHITE_UV, egui::epaint::WHITE_UV)
} else {
uv.unwrap_or(Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)))
};
mesh.add_rect_with_uv(Rect::from_center_size(center, size), uv, tint);
let rot = egui::emath::Rot2::from_angle(angle);
for v in &mut mesh.vertices[start..] {
v.pos = center + rot * (v.pos - center);
}
}
fn value_index(w: &bob::Widget, s: &PvState) -> Option<i64> {
let at = |len: usize| w.i64("array_index").unwrap_or(0).clamp(0, len as i64 - 1) as usize;
match &s.value {
Value::Str(t) => t.trim().parse().ok(),
Value::DoubleArray(a) if !a.is_empty() => Some(a[at(a.len())] as i64),
Value::LongArray(a) if !a.is_empty() => Some(a[at(a.len())]),
Value::Double(_) | Value::Long(_) | Value::Enum(_) => s.value.as_i64(),
_ => None,
}
}
fn strip_width(w: &bob::Widget) -> Option<f32> {
w.f64("sub_image_width")
.filter(|sw| *sw > 0.0)
.map(|sw| sw as f32)
}
fn strip_uv(width: f32, frame: f32, index: i64) -> Rect {
let frames = (width / frame).floor().max(1.0) as i64;
let i = if (0..frames).contains(&index) {
index
} else {
0
};
let u = |n: i64| (n as f32 * frame / width).min(1.0);
Rect::from_min_max(Pos2::new(u(i), 0.0), Pos2::new(u(i + 1), 1.0))
}
fn index(node: &Node, s: Option<&PvState>) -> i64 {
if strip_width(&node.w).is_some() {
return crate::group_link::frame(node, s)
.unwrap_or_else(|| node.w.i64("image_index").unwrap_or(0));
}
let value = s
.filter(|s| s.connected)
.and_then(|s| value_index(&node.w, s));
value
.or(node.symbol.index)
.unwrap_or_else(|| node.w.i64("initial_index").unwrap_or(0))
}
pub fn draw_symbol(
ui: &mut egui::Ui,
node: &mut Node,
s: Option<&PvState>,
rect: Rect,
cx: &mut Cx,
id: Id,
) -> Option<Response> {
let w = &node.w;
let painter = ui.painter().with_clip_rect(cx.clip);
let z = cx.view.zoom;
let angle = (w.f64("rotation").unwrap_or(0.0) as f32).to_radians();
let actions = w.actions();
let clickable = w.bool("run_actions_on_mouse_click") == Some(true)
&& w.bool("enabled") != Some(false)
&& !actions.is_empty();
let resp = clickable.then(|| {
ui.interact(rect.intersect(cx.clip), id, Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
});
if w.bool("transparent") == Some(false) {
let bg = w
.color("background_color")
.map(|c| color(&c))
.unwrap_or(named("Background"));
let mut mesh = Mesh::default();
quad(&mut mesh, rect.center(), rect.size(), angle, bg, None);
painter.add(mesh);
}
let symbols = w.list("symbols", "symbol");
let idx = index(node, s);
node.symbol.index = Some(idx);
let strip = strip_width(w).map(|sw| (sw, idx));
let file = match usize::try_from(if strip.is_some() { 0 } else { idx }) {
Ok(i) => symbols
.get(i)
.cloned()
.or_else(|| w.text("fallback_symbol").map(str::to_string)),
Err(_) => None,
};
let file = file
.map(|f| node.expand(&f))
.filter(|f| !f.trim().is_empty());
let fallback = w
.text("fallback_symbol")
.map(|f| node.expand(f))
.filter(|f| !f.trim().is_empty());
let blank = w.text("fallback_symbol").is_some() && fallback.is_none();
let tint = match &resp {
Some(r) if r.is_pointer_button_down_on() => Color32::from_gray(180),
_ => Color32::WHITE,
};
let glow = resp
.as_ref()
.is_some_and(|r| r.hovered() && !r.is_pointer_button_down_on());
if let Some(file) = file {
let shown = show(
ui,
node,
&[
&file,
fallback.as_deref().filter(|f| *f != file).unwrap_or(""),
],
rect,
angle,
tint,
glow,
strip,
&painter,
);
if !shown && !blank {
placeholder(&painter, rect, "symbol", z);
}
} else if idx >= 0 && !blank {
placeholder(&painter, rect, "symbol", z);
}
let w = &node.w;
if w.bool("show_index") == Some(true) && rect.width() >= 20.0 && rect.height() >= 20.0 {
let text = idx.to_string();
let font = FontId::proportional(16.0 * z);
let galley = painter.layout_no_wrap(text, font, Color32::WHITE);
let label = Rect::from_center_size(rect.center(), galley.size() + Vec2::splat(4.0 * z));
painter.rect_filled(label, CornerRadius::same(3), Color32::from_black_alpha(140));
painter.galley(
Align2::CENTER_CENTER
.align_size_within_rect(galley.size(), label)
.min,
galley,
Color32::WHITE,
);
}
if s.is_some_and(|s| !s.connected) {
let overlay = w
.color("disconnect_overlay_color")
.map(|c| color(&c))
.unwrap_or_else(|| named("INVALID").gamma_multiply(0.5));
let mut mesh = Mesh::default();
quad(&mut mesh, rect.center(), rect.size(), angle, overlay, None);
painter.add(mesh);
}
if let Some(r) = &resp
&& r.clicked()
{
for a in &actions {
run_action(node, a, cx);
}
}
resp
}
pub fn draw_picture(ui: &mut egui::Ui, node: &mut Node, rect: Rect, cx: &Cx) {
let painter = ui.painter().with_clip_rect(cx.clip);
let file = node.prop("file");
if file.trim().is_empty() {
placeholder(&painter, rect, "picture", cx.view.zoom);
return;
}
let angle = (node.w.f64("rotation").unwrap_or(0.0) as f32).to_radians();
let opacity = node.w.f64("opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
let tint = Color32::WHITE.gamma_multiply(opacity);
if !show(ui, node, &[&file], rect, angle, tint, false, None, &painter) {
placeholder(&painter, rect, "picture", cx.view.zoom);
}
}
#[allow(clippy::too_many_arguments)]
fn show(
ui: &egui::Ui,
node: &mut Node,
files: &[&str],
rect: Rect,
angle: f32,
tint: Color32,
glow: bool,
strip: Option<(f32, i64)>,
painter: &egui::Painter,
) -> bool {
let ctx = ui.ctx();
for file in files.iter().filter(|f| !f.trim().is_empty()) {
let source = node.resolve(file);
let img = match image(ctx, &source) {
None => return true,
Some(Err(())) => continue,
Some(Ok(img)) => img,
};
let (native, uv) = match strip {
Some((frame, index)) => (
Vec2::new(frame.min(img.size().x), img.size().y),
Some(strip_uv(img.size().x, frame, index)),
),
None => (img.size(), None),
};
let size = picture_size(&node.w, native, rect.size(), angle);
let tex_size = match uv {
Some(uv) => size / Vec2::new(uv.width().max(1e-3), 1.0),
None => size,
};
let Some(handle) = texture(ctx, &mut node.symbol.texture, &source, &img, tex_size) else {
continue;
};
let mut mesh = Mesh::with_texture(handle.id());
quad(&mut mesh, rect.center(), size, angle, tint, uv);
if glow {
quad(
&mut mesh,
rect.center(),
size,
angle,
Color32::from_rgba_premultiplied(77, 77, 77, 0),
uv,
);
}
painter.add(mesh);
return true;
}
false
}
fn picture_size(w: &bob::Widget, native: Vec2, room: Vec2, angle: f32) -> Vec2 {
if w.kind != "picture" {
return if w.bool("preserve_ratio") != Some(false) {
fit(native, room)
} else {
room
};
}
let size = if w.bool("stretch_image") == Some(true) {
room
} else {
fit(native, room)
};
let (sin, cos) = (angle.sin().abs(), angle.cos().abs());
let bounds = Vec2::new(size.x * cos + size.y * sin, size.x * sin + size.y * cos);
let scale = (room.x / bounds.x).min(room.y / bounds.y);
if scale < 1.0 { size * scale } else { size }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn svgs_are_redrawn_in_steps_as_they_grow() {
let mut sizes: Vec<[usize; 2]> = (100..=400)
.map(|w| svg_pixels(Vec2::new(w as f32, w as f32 / 2.0), 4096.0))
.collect();
for (i, [w, h]) in sizes.iter().enumerate() {
let want = 100 + i;
assert!(
*w >= want && (*w as f32) < want as f32 * 1.2,
"{w} for {want}"
);
assert!(h.abs_diff(w / 2) <= 1, "the shape is kept: {w} x {h}");
}
sizes.dedup();
assert!(sizes.len() <= 9, "{} renders", sizes.len());
assert_eq!(svg_pixels(Vec2::new(9000.0, 3000.0), 4096.0), [4096, 1365]);
assert_eq!(svg_pixels(Vec2::ZERO, 4096.0), [1, 1]);
}
fn widget(xml: &str) -> bob::Widget {
let d = bob::Display::parse(&format!("<display>{xml}</display>")).unwrap();
d.widgets.into_iter().next().unwrap()
}
fn crc32(bytes: &[u8]) -> u32 {
let mut crc = !0u32;
for &b in bytes {
crc ^= u32::from(b);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xEDB8_8320 & (!(crc & 1)).wrapping_add(1));
}
}
!crc
}
fn png_claiming(width: u32, height: u32) -> Vec<u8> {
let mut out = Vec::new();
let mut enc = png::Encoder::new(&mut out, 1, 1);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[1, 2, 3, 4]).unwrap();
w.finish().unwrap();
out[16..20].copy_from_slice(&width.to_be_bytes());
out[20..24].copy_from_slice(&height.to_be_bytes());
let crc = crc32(&out[12..29]);
out[29..33].copy_from_slice(&crc.to_be_bytes());
out
}
#[test]
fn png_decompression_bomb_is_refused_before_allocating() {
assert!(decode_png(&png_claiming(1, 1)).is_ok());
let bomb = png_claiming(30_000, 30_000);
let err = decode_png(&bomb).unwrap_err();
assert!(err.contains("too large"), "{err}");
assert!(decode_png(&png_claiming(u32::MAX >> 1, 1)).is_err());
}
#[test]
fn svg_with_embedded_png_bomb_drops_the_image() {
use std::fmt::Write;
let mut b64 = String::new();
let bomb = png_claiming(30_000, 30_000);
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
for c in bomb.chunks(3) {
let n = (u32::from(c[0]) << 16)
| (u32::from(*c.get(1).unwrap_or(&0)) << 8)
| u32::from(*c.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= c.len() {
b64.push(T[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
b64.push('=');
}
}
}
let mut svg = String::new();
write!(
svg,
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="10" height="10"><image width="10" height="10" xlink:href="data:image/png;base64,{b64}"/></svg>"#
)
.unwrap();
let img = decode(&Source::File("x.svg".into()), svg.as_bytes()).unwrap();
let Image::Svg(tree) = img else { panic!() };
assert!(tree.root().children().is_empty());
}
#[test]
fn fits_keeping_the_aspect_ratio() {
assert_eq!(
fit(Vec2::new(10.0, 20.0), Vec2::new(40.0, 40.0)),
Vec2::new(20.0, 40.0)
);
assert_eq!(
fit(Vec2::new(20.0, 10.0), Vec2::new(40.0, 40.0)),
Vec2::new(40.0, 20.0)
);
}
#[test]
fn symbols_fit_before_turning_and_pictures_after() {
let room = Vec2::new(100.0, 100.0);
let native = Vec2::new(10.0, 10.0);
let angle = 45f32.to_radians();
let sym = widget(r#"<widget type="symbol"/>"#);
assert_eq!(picture_size(&sym, native, room, angle), room);
let pic = widget(r#"<widget type="picture"/>"#);
let s = picture_size(&pic, native, room, angle);
assert!((s.x - 100.0 / 2f32.sqrt()).abs() < 0.01, "{s:?}");
let stretch =
widget(r#"<widget type="symbol"><preserve_ratio>false</preserve_ratio></widget>"#);
assert_eq!(
picture_size(&stretch, Vec2::new(10.0, 20.0), room, 0.0),
room
);
}
#[test]
fn pv_values_pick_the_symbol() {
let w = widget(r#"<widget type="symbol"><array_index>1</array_index></widget>"#);
let state = |value| PvState {
connected: true,
value,
..Default::default()
};
assert_eq!(value_index(&w, &state(Value::Double(2.7))), Some(2));
assert_eq!(value_index(&w, &state(Value::Enum(3))), Some(3));
assert_eq!(value_index(&w, &state(Value::Str(" 4 ".into()))), Some(4));
assert_eq!(value_index(&w, &state(Value::Str("on".into()))), None);
let arr = Value::LongArray(Arc::from([5, 6, 7]));
assert_eq!(value_index(&w, &state(arr)), Some(6));
assert_eq!(value_index(&w, &state(Value::None)), None);
}
#[test]
fn strips_show_one_frame() {
let uv = strip_uv(102.0, 34.0, 1);
assert!((uv.min.x - 1.0 / 3.0).abs() < 1e-6 && (uv.max.x - 2.0 / 3.0).abs() < 1e-6);
assert_eq!(strip_uv(102.0, 34.0, 7), strip_uv(102.0, 34.0, 0));
assert_eq!(strip_uv(102.0, 34.0, -1).min.x, 0.0);
assert_eq!(strip_uv(20.0, 34.0, 0).max.x, 1.0);
let w = widget(r#"<widget type="symbol"><sub_image_width>34</sub_image_width></widget>"#);
assert_eq!(strip_width(&w), Some(34.0));
assert_eq!(strip_width(&widget(r#"<widget type="symbol"/>"#)), None);
}
#[test]
fn decodes_svg_and_png() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="4"><rect width="8" height="4" fill="red"/></svg>"#;
let src = Source::parse("a.svg");
let Ok(Image::Svg(tree)) = decode(&src, svg) else {
panic!("not an SVG")
};
let img = render_svg(&tree, [8, 4]);
assert_eq!(img.pixels[0], Color32::RED);
let mut png = Vec::new();
let mut enc = png::Encoder::new(&mut png, 2, 1);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
enc.write_header()
.unwrap()
.write_image_data(&[0, 0, 255, 0, 255, 0])
.unwrap();
let Ok(Image::Raster(img)) = decode(&Source::parse("b.png"), &png) else {
panic!("not a PNG")
};
assert_eq!(img.size, [2, 1]);
assert_eq!(img.pixels[0], Color32::BLUE);
assert!(decode(&Source::parse("c.gif"), b"GIF89a").is_err());
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/displays/symbols/stripe.png");
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><image href="{}" width="4" height="4"/></svg>"#,
here.canonicalize().unwrap().display()
);
let Ok(Image::Svg(tree)) = decode(&src, svg.as_bytes()) else {
panic!("not an SVG")
};
assert!(!tree.root().has_children());
}
}