use super::*;
use deckcraft_model::chart::ChartType;
use deckcraft_model::style::ColorRef;
use deckcraft_model::text::TextBody;
use deckcraft_model::{
Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm,
};
fn deck_with(shapes: Vec<Shape>) -> Presentation {
let mut p = Presentation::default();
let s = std::sync::Arc::make_mut(&mut p.slides[0]);
s.shapes = shapes;
p
}
#[test]
fn blank_slide_renders_white_background() {
let p = Presentation::default();
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 0.25,
..Default::default()
},
);
assert_eq!((img.width, img.height), (240, 135));
assert_eq!(img.pixel(5, 5), [255, 255, 255, 255]);
}
#[test]
fn shape_fill_uses_theme_accent() {
let sh = Shape {
id: ShapeId(500),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let accent = p.masters[0]
.theme
.colors
.get(deckcraft_color::SchemeSlot::Accent1);
let px = img.pixel(200, 150);
assert!(
(px[0] as i32 - accent.r as i32).abs() <= 2 && (px[2] as i32 - accent.b as i32).abs() <= 2,
"{px:?} vs {accent:?}"
);
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]);
}
#[test]
fn text_draws_ink() {
let mut sh = Shape {
id: ShapeId(501),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
text_box: true,
..Default::default()
};
let mut t = TextBody::from_text("HELLO WORLD");
t.paragraphs[0].runs[0].props.size = Some(120.0);
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 0.5,
..Default::default()
},
);
let dark = img
.pixels
.as_chunks::<4>()
.0
.iter()
.filter(|px| px[0] < 128)
.count();
assert!(dark > 500, "dark pixels {dark}");
}
#[test]
fn every_preset_renders_without_panic() {
let shapes: Vec<Shape> = deckcraft_geom::preset::CATALOG
.iter()
.enumerate()
.map(|(i, pr)| Shape {
id: ShapeId(1000 + i as u32),
xfrm: Some(Xfrm::new(
(i % 16) as f64 * 60.0,
(i / 16) as f64 * 55.0,
50.0,
45.0,
)),
geom: Geom::preset(pr.name),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
..Default::default()
})
.collect();
let p = deck_with(shapes);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
edit: true,
..Default::default()
},
);
assert_eq!(img.width, 960);
}
#[test]
fn effects_tables_charts_render() {
let mut sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(50.0, 50.0, 200.0, 100.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
sh.effects = Some(deckcraft_model::Effects {
outer_shadow: Some(deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
blur: 8.0,
dist: 6.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}),
glow: Some(deckcraft_model::style::Glow {
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 200, 0)),
radius: 6.0,
}),
soft_edge: Some(4.0),
..Default::default()
});
let mut t = Table::new(3, 3, 300.0, 30.0);
t.cell_mut(0, 0).unwrap().text = TextBody::from_text("Head");
let tbl = Shape {
id: ShapeId(601),
xfrm: Some(Xfrm::new(300.0, 50.0, 300.0, 90.0)),
kind: ShapeKind::Table(t),
..Default::default()
};
let mut charts = vec![];
for (i, k) in [
ChartType::Column,
ChartType::Bar,
ChartType::Line,
ChartType::Pie,
ChartType::Doughnut,
ChartType::Area,
ChartType::Scatter,
ChartType::StackedColumn,
]
.iter()
.enumerate()
{
charts.push(Shape {
id: ShapeId(700 + i as u32),
xfrm: Some(Xfrm::new(i as f64 * 110.0, 300.0, 100.0, 100.0)),
kind: ShapeKind::Chart(Box::new(Chart::sample(*k))),
..Default::default()
});
}
let mut all = vec![sh, tbl];
all.extend(charts);
let p = deck_with(all);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let px = img.pixel(255, 155);
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
}
#[test]
fn placeholder_prompts_only_in_edit_view() {
let p = Presentation::default();
let edit = render_slide(
&p,
0,
&RenderOpts {
scale: 0.5,
edit: true,
..Default::default()
},
);
let show = render_slide(
&p,
0,
&RenderOpts {
scale: 0.5,
..Default::default()
},
);
let ink = |i: &Image| {
i.pixels
.as_chunks::<4>()
.0
.iter()
.filter(|px| px[0] < 200)
.count()
};
assert!(ink(&edit) > 100);
assert_eq!(ink(&show), 0);
}
#[test]
fn png_encodes() {
let p = Presentation::default();
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 0.1,
..Default::default()
},
);
let png = img.to_png();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
assert!(!img.to_jpeg(80).is_empty());
}
#[test]
fn crop_dest_math() {
let r = crop_dest(Rect::new(0.0, 0.0, 100.0, 100.0), [0.5, 0.0, 0.0, 0.0]);
assert!((r.x0 + 100.0).abs() < 1e-9 && (r.width() - 200.0).abs() < 1e-9);
let r = crop_dest(
Rect::new(0.0, 0.0, 100.0, 100.0),
[f64::NAN, 2.0, -50.0, 0.0],
);
assert!(r.x0.is_finite() && r.y1.is_finite());
}
#[test]
fn custom_path_parse() {
let p = parse_path("M 0 0 L 10 0 C 1 2 3 4 5 6 Q 1 1 2 2 Z garbage L x y");
assert!(p.elements().len() >= 5);
assert!(parse_path("").elements().is_empty());
}
#[test]
fn table_rows_of_zero_height_grow_to_fit_text() {
let mut t = Table::new(3, 2, 300.0, 0.0);
for (r, row) in [["Col A", "Col B"], ["1", "alpha"], ["2", "beta"]]
.iter()
.enumerate()
{
for (c, text) in row.iter().enumerate() {
t.cell_mut(r, c).unwrap().text = TextBody::from_text(text);
}
}
let tbl = Shape {
id: ShapeId(610),
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 0.0)),
kind: ShapeKind::Table(t.clone()),
..Default::default()
};
let p = deck_with(vec![tbl]);
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
let hs = crate::table::row_heights(&ctx, &t, &deckcraft_text::NoFields);
assert_eq!(hs.len(), 3);
assert!(hs.iter().all(|h| *h > 10.0), "{hs:?}");
let tops: Vec<f64> = crate::table::cell_rects(&t, &hs)
.iter()
.filter(|(_, c, _)| *c == 0)
.map(|(_, _, r)| r.y0)
.collect();
assert!(
tops.len() == 3 && tops.windows(2).all(|w| w[1] > w[0] + 10.0),
"{tops:?}"
);
let mut tall = t;
tall.rows[1].height = 200.0;
assert_eq!(
crate::table::row_heights(&ctx, &tall, &deckcraft_text::NoFields)[1],
200.0
);
let placed = place_slide(&p, 0);
let top = |s: &str| {
let pt = placed
.texts
.iter()
.find(|pt| pt.body.text() == s)
.unwrap_or_else(|| panic!("no placed text {s:?}"));
(pt.transform * kurbo::Point::new(0.0, pt.layout.lines[0].top)).y
};
assert!(top("Col A") < top("1") && top("1") < top("2"));
assert!((top("alpha") - top("1")).abs() < 1.0 && (top("beta") - top("2")).abs() < 1.0);
assert!(top("Col B") >= 100.0);
}
#[test]
fn blend_ends_match_the_slides_and_mixes_backdrops() {
let sh = Shape {
id: ShapeId(500),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let mut p = deck_with(vec![sh.clone()]);
let mut dark = (*p.slides[0]).clone();
dark.background = Some(deckcraft_model::Background::Fill {
fill: deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))),
});
p.slides.push(std::sync::Arc::new(dark));
let o = RenderOpts {
scale: 0.5,
..Default::default()
};
let shapes = vec![(sh, false)];
assert_eq!(
render_blend(&p, 0, 1, 1.0, &shapes, &[], &[], &o).pixels,
render_slide(&p, 1, &o).pixels
);
let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &[], &o);
let px = mid.pixel(5, 5);
assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}");
assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75));
assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &[], &o).width, 0);
let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o);
}
fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape {
let mut t = TextBody::from_text(text);
for p in &mut t.paragraphs {
for r in &mut p.runs {
r.props.size = Some(40.0);
}
}
Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(x, y, 600.0, 120.0)),
text_box: true,
text: Some(t),
..Default::default()
}
}
#[test]
fn morph_text_tokens_are_words_or_characters() {
let p = Presentation::default();
let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
let s = text_shape(1, "Hello big world\nHello", 0.0, 0.0);
let l = deckcraft_text::layout(
&rctx,
&s,
s.text.as_ref().unwrap(),
&Opts {
rect: Rect::new(0.0, 0.0, 600.0, 120.0),
fields: &deckcraft_text::NoFields,
prompt_color: None,
no_shrink: false,
},
);
assert_eq!(
morph_text::token_keys(&l, false),
["Hello", "big", "world", "Hello"]
);
assert_eq!(
morph_text::token_keys(&l, true).concat(),
"Hellobigworld".to_string() + "Hello"
);
assert_eq!(morph_text::token_keys(&l, true).len(), 18);
}
#[test]
fn morph_text_ends_match_the_slides() {
let from = Xfrm::new(100.0, 60.0, 600.0, 120.0);
let to = Xfrm::new(200.0, 300.0, 600.0, 120.0);
let mut p = deck_with(vec![text_shape(1, "Hello wide world", from.x, from.y)]);
let mut b = (*p.slides[0]).clone();
b.shapes = vec![text_shape(1, "world says Hello", to.x, to.y)];
p.slides.push(std::sync::Arc::new(b));
let o = RenderOpts {
scale: 0.5,
..Default::default()
};
let frame = |t: f64| {
let x = Xfrm::new(
from.x + (to.x - from.x) * t,
from.y + (to.y - from.y) * t,
600.0,
120.0,
);
let mut sb = text_shape(10, "world says Hello", 0.0, 0.0);
let mut sa = text_shape(11, "Hello wide world", 0.0, 0.0);
sb.xfrm = Some(x);
sa.xfrm = Some(x);
let tm = TextMorph {
old: ShapeId(11),
new: ShapeId(10),
from,
to,
chars: false,
t,
};
render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &[], &o)
};
let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5));
let close = |a: &Image, b: &Image| {
a.pixels
.iter()
.zip(&b.pixels)
.filter(|(x, y)| x.abs_diff(**y) > 8)
.count()
};
let d0 = close(&frame(0.0), &render_slide(&p, 0, &o));
assert!(d0 < 40, "start differs {d0}");
let d1 = close(&frame(1.0), &render_slide(&p, 1, &o));
assert!(d1 < 40, "end differs {d1}");
let mid = frame(0.5);
let ink = (95..140)
.flat_map(|y| (0..480).map(move |x| (x, y)))
.filter(|&(x, y)| mid.pixel(x, y)[0] < 128)
.count();
assert!(ink > 50, "ink {ink}");
}
fn field_shape(kind: Option<&str>, saved: &str) -> Shape {
let mut sh = text_shape(1, saved, 40.0, 40.0);
if let (Some(k), Some(run)) = (
kind,
sh.text
.as_mut()
.and_then(|t| t.paragraphs[0].runs.first_mut()),
) {
run.kind = deckcraft_model::text::RunKind::Field { field: k.into() };
}
sh
}
#[test]
fn date_fields_show_the_date_in_their_format() {
use deckcraft_text::datetime::DateTime;
let fri = DateTime::new(2026, 10, 9, 20, 5, 7);
let at = |p: &Presentation, now: DateTime| {
render_slide(
p,
0,
&RenderOpts {
scale: 0.5,
now: Some(now),
..Default::default()
},
)
};
let mut p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
let _ = at(&p, fri); let a = at(&p, fri);
assert!(diff_px(&a, &at(&p, DateTime::new(2027, 3, 4, 0, 0, 0))) > 0);
let shown = deck_with(vec![field_shape(None, "10/9/2026")]);
assert_eq!(diff_px(&a, &at(&shown, fri)), 0, "datetime1 is M/D/YYYY");
p.header_footer.date_text = "Fixed on another slide".into();
assert_eq!(diff_px(&a, &at(&p, fri)), 0);
let f = Renderer::fields(
&p,
0,
&RenderOpts {
now: Some(fri),
..Default::default()
},
);
assert_eq!(
f.field_in("datetime4", Some("en-US")).as_deref(),
Some("October 9, 2026")
);
assert_eq!(
f.field_in("datetimeFigureOut", Some("en-GB")).as_deref(),
Some("09/10/2026")
);
assert_eq!(f.field("datetime13").as_deref(), Some("8:05:07 PM"));
assert_eq!(f.field("slidenum").as_deref(), Some("1"));
assert_eq!(SlideFields { now: None, ..f }.field("datetime1"), None);
let unknown = deck_with(vec![field_shape(Some("datetime99"), "Saved text")]);
let plain = deck_with(vec![field_shape(None, "Saved text")]);
assert_eq!(diff_px(&at(&unknown, fri), &at(&plain, fri)), 0);
let o = RenderOpts {
date_text: Some("Today".into()),
now: Some(fri),
..Default::default()
};
assert_eq!(
Renderer::fields(&p, 0, &o).field("datetime2").as_deref(),
Some("Today")
);
}
fn diff_px(a: &Image, b: &Image) -> usize {
a.pixels
.iter()
.zip(&b.pixels)
.filter(|(x, y)| x.abs_diff(**y) > 64)
.count()
}
fn two_frame_gif() -> Vec<u8> {
use image::{Delay, Frame, Rgba, RgbaImage};
let frame = |c: [u8; 4]| {
Frame::from_parts(
RgbaImage::from_pixel(8, 8, Rgba(c)),
0,
0,
Delay::from_numer_denom_ms(100, 1),
)
};
let mut out = Vec::new();
image::codecs::gif::GifEncoder::new(&mut out)
.encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])])
.expect("gif");
out
}
#[test]
fn animated_gif_shows_the_frame_for_its_time() {
let mut p = Presentation::default();
let media = p.add_media("a.gif", "image/gif", two_frame_gif());
let fill = deckcraft_model::style::PictureFill {
media,
..Default::default()
};
let sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
kind: ShapeKind::Picture { fill },
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let at = |t: Option<f64>| {
let times: Vec<(ShapeId, f64)> = t.map(|t| vec![(ShapeId(600), t)]).unwrap_or_default();
render_slide(
&p,
0,
&RenderOpts {
scale: 0.1,
gif_times: ×,
..Default::default()
},
)
.pixel(48, 27)
};
let red = |px: [u8; 4]| px[0] > 200 && px[2] < 60;
let blue = |px: [u8; 4]| px[2] > 200 && px[0] < 60;
assert!(
red(at(None)),
"no time: the first frame, as exports draw it"
);
assert!(red(at(Some(0.05))));
assert!(blue(at(Some(0.15))));
assert!(red(at(Some(0.25))), "it loops");
let bytes = p.media(media).map(|m| m.data.clone()).expect("media");
let adj = Default::default();
assert_eq!(gif_frame(&bytes, &adj, 0.15).map(|f| f.0), Some(1));
assert_eq!(gif_frame(&bytes, &adj, f64::NAN).map(|f| f.0), Some(0));
assert!(gif_frame(&std::sync::Arc::new(b"\x89PNG".to_vec()), &adj, 0.0).is_none());
}
#[test]
fn morph_path_ends_match_the_slides() {
let from = Xfrm::new(100.0, 100.0, 200.0, 200.0);
let to = Xfrm::new(500.0, 150.0, 300.0, 150.0);
let form = |id: u32, g: &str, x: Xfrm| Shape {
id: ShapeId(id),
xfrm: Some(x),
geom: Geom::preset(g),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let mut p = deck_with(vec![form(1, "ellipse", from)]);
let mut b = (*p.slides[0]).clone();
b.shapes = vec![form(1, "star5", to)];
p.slides.push(std::sync::Arc::new(b));
let o = RenderOpts {
scale: 0.5,
..Default::default()
};
let frame = |t: f64| {
let lerp = |a: f64, b: f64| a + (b - a) * t;
let x = Xfrm::new(
lerp(from.x, to.x),
lerp(from.y, to.y),
lerp(from.w, to.w),
lerp(from.h, to.h),
);
let pm = PathMorph {
id: ShapeId(10),
from: Geom::preset("ellipse"),
from_box: from,
to_box: to,
t,
};
render_blend(
&p,
0,
1,
t,
&[(form(10, "star5", x), false)],
&[],
&[pm],
&o,
)
};
let differ = |a: &Image, b: &Image| {
a.pixels
.iter()
.zip(&b.pixels)
.filter(|(x, y)| x.abs_diff(**y) > 60)
.count()
};
let d0 = differ(&frame(0.0), &render_slide(&p, 0, &o));
assert!(d0 < 40, "start differs {d0}");
let d1 = differ(&frame(1.0), &render_slide(&p, 1, &o));
assert!(d1 < 40, "end differs {d1}");
let star = render_blend(
&p,
0,
1,
0.0,
&[(form(10, "star5", from), false)],
&[],
&[],
&o,
);
assert!(differ(&star, &render_slide(&p, 0, &o)) > 400);
let mid = frame(0.5);
let c = (from.center().to_vec2() + to.center().to_vec2()) * 0.5 * 0.5;
assert_ne!(mid.pixel(c.x as u32, c.y as u32), [255, 255, 255, 255]);
}
#[test]
fn morph_path_fades_a_fill_only_one_side_has() {
let bx = Xfrm::new(100.0, 100.0, 200.0, 200.0);
let form = |id: u32, g: &str| Shape {
id: ShapeId(id),
xfrm: Some(bx),
geom: Geom::preset(g),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let mut p = deck_with(vec![form(1, "rect")]);
let mut b = (*p.slides[0]).clone();
b.shapes = vec![form(1, "line")];
p.slides.push(std::sync::Arc::new(b));
let o = RenderOpts {
scale: 0.5,
..Default::default()
};
let at = |t: f64| {
let pm = PathMorph {
id: ShapeId(10),
from: Geom::preset("rect"),
from_box: bx,
to_box: bx,
t,
};
render_blend(&p, 0, 1, t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90)
};
let (full, half, none) = (at(0.0), at(0.5), at(1.0));
assert_eq!(none, [255, 255, 255, 255]);
assert!(full[0] < 128, "{full:?}");
assert!(
half[0] > full[0] + 40 && half[0] < 215,
"{half:?} vs {full:?}"
);
}
#[test]
fn text_transform_unmirrors_flips() {
let tr = Rect::new(10.0, 0.0, 40.0, 50.0);
let at = |x: Xfrm, p: Point| text_transform(x.affine(), tr, 0.0) * p;
let near = |a: Point, b: Point| (a - b).hypot() < 1e-9;
let base = Xfrm::new(0.0, 0.0, 100.0, 50.0);
let h = Xfrm {
flip_h: true,
..base
};
assert!(near(at(h, Point::new(10.0, 0.0)), Point::new(60.0, 0.0)));
assert!(near(at(h, Point::new(40.0, 50.0)), Point::new(90.0, 50.0)));
let v = Xfrm {
flip_v: true,
..base
};
assert!(near(at(v, Point::new(10.0, 0.0)), Point::new(40.0, 50.0)));
assert!(near(at(v, Point::new(40.0, 50.0)), Point::new(10.0, 0.0)));
let hv = Xfrm {
flip_h: true,
flip_v: true,
..base
};
let turned = Xfrm { rot: 180.0, ..base };
for p in [
Point::new(10.0, 0.0),
Point::new(40.0, 50.0),
Point::new(25.0, 10.0),
] {
assert!(near(at(hv, p), at(turned, p)));
}
assert_eq!(text_transform(base.affine(), tr, 0.0), base.affine());
let _ = text_transform(
h.affine(),
Rect::new(f64::NAN, 0.0, f64::INFINITY, 1.0),
f64::NAN,
);
}
fn flipped_text(x: Xfrm, group: Option<Xfrm>) -> Image {
let mut s = text_shape(1, "Fly 42 RJ", 0.0, 0.0);
s.xfrm = Some(x);
let s = match group {
Some(g) => Shape {
id: ShapeId(2),
xfrm: Some(g),
kind: ShapeKind::Group {
children: vec![s],
child: Xfrm::new(0.0, 0.0, 600.0, 120.0),
},
..Default::default()
},
None => s,
};
render_slide(
&deck_with(vec![s]),
0,
&RenderOpts {
scale: 0.5,
..Default::default()
},
)
}
fn differing(a: &Image, b: &Image) -> usize {
a.pixels
.iter()
.zip(&b.pixels)
.filter(|(x, y)| x.abs_diff(**y) > 64)
.count()
}
#[test]
fn flipped_shapes_draw_readable_text() {
let base = Xfrm::new(180.0, 200.0, 600.0, 120.0);
let plain = flipped_text(base, None);
let plain = if differing(&plain, &flipped_text(base, None)) > 0 {
flipped_text(base, None)
} else {
plain
};
let turned = flipped_text(Xfrm { rot: 180.0, ..base }, None);
assert!(
differing(&plain, &turned) > 200,
"the comparisons below must mean something"
);
let d = differing(
&plain,
&flipped_text(
Xfrm {
flip_h: true,
..base
},
None,
),
);
assert!(d < 40, "flipH mirrored the text: {d}");
let d = differing(
&turned,
&flipped_text(
Xfrm {
flip_v: true,
..base
},
None,
),
);
assert!(d < 40, "flipV mirrored the text: {d}");
let d = differing(
&turned,
&flipped_text(
Xfrm {
flip_h: true,
flip_v: true,
..base
},
None,
),
);
assert!(d < 40, "flipH+flipV: {d}");
let rot = Xfrm { rot: 30.0, ..base };
let d = differing(
&flipped_text(rot, None),
&flipped_text(
Xfrm {
flip_h: true,
..rot
},
None,
),
);
assert!(d < 40, "rotated flipH: {d}");
let child = Xfrm::new(0.0, 0.0, 600.0, 120.0);
let d = differing(
&plain,
&flipped_text(
child,
Some(Xfrm {
flip_h: true,
..base
}),
),
);
assert!(d < 40, "group flipH: {d}");
let d = differing(
&plain,
&flipped_text(
Xfrm {
flip_h: true,
..child
},
Some(Xfrm {
flip_h: true,
..base
}),
),
);
assert!(d < 40, "flipH child in flipH group: {d}");
let d = differing(
&turned,
&flipped_text(
child,
Some(Xfrm {
flip_v: true,
..base
}),
),
);
assert!(d < 40, "group flipV: {d}");
}
fn run_deck(props: deckcraft_model::RunProps) -> Presentation {
let mut sh = text_shape(1, "Linked text", 40.0, 40.0);
if let Some(run) = sh
.text
.as_mut()
.and_then(|t| t.paragraphs[0].runs.first_mut())
{
run.props = deckcraft_model::RunProps {
size: Some(40.0),
..props
};
}
deck_with(vec![sh])
}
#[test]
fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
use deckcraft_model::RunProps;
use deckcraft_model::text::{Action, Hyperlink};
let o = RenderOpts {
scale: 0.5,
..Default::default()
};
let px = |p: &Presentation| render_slide(p, 0, &o);
let diff = |a: &Image, b: &Image| {
a.pixels
.iter()
.zip(&b.pixels)
.filter(|(x, y)| x.abs_diff(**y) > 64)
.count()
};
let link = Some(Hyperlink {
action: Action::Url {
url: "https://example.org".into(),
},
tooltip: String::new(),
highlight_click: false,
});
let hlink = Some(Fill::solid(ColorRef::scheme(
deckcraft_color::SchemeSlot::Hlink,
)));
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))));
let _ = px(&run_deck(RunProps::default())); let linked = px(&run_deck(RunProps {
link: link.clone(),
..Default::default()
}));
assert!(
diff(&linked, &px(&run_deck(RunProps::default()))) > 100,
"a link doesn't look like plain text"
);
let styled = px(&run_deck(RunProps {
fill: hlink,
underline: Some("sng".into()),
..Default::default()
}));
assert_eq!(diff(&linked, &styled), 0);
let own = px(&run_deck(RunProps {
link: link.clone(),
fill: red.clone(),
underline: Some("none".into()),
..Default::default()
}));
assert_eq!(
diff(
&own,
&px(&run_deck(RunProps {
fill: red.clone(),
..Default::default()
}))
),
0
);
let own = px(&run_deck(RunProps {
link,
fill: red.clone(),
..Default::default()
}));
assert_eq!(
diff(
&own,
&px(&run_deck(RunProps {
fill: red,
underline: Some("sng".into()),
..Default::default()
}))
),
0
);
}
#[test]
fn a_linear_gradient_ramps_like_powerpoint() {
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, c| GradientStop {
pos,
color: ColorRef::rgb(c),
};
let sh = Shape {
id: ShapeId(700),
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![
stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)),
stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255)),
],
shape: GradientShape::Linear {
angle: 0.0,
scaled: false,
},
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line {
fill: Some(Fill::None),
..Default::default()
}),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
for (x, want) in [(200, 105), (400, 186), (600, 238)] {
let got = img.pixel(x, 50)[0] as i32;
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
}
}
#[test]
fn a_gradient_with_a_stop_off_the_end_stays_linear() {
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, c| GradientStop {
pos,
color: ColorRef::rgb(c),
};
let sh = Shape {
id: ShapeId(701),
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![
stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)),
stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255)),
],
shape: GradientShape::Linear {
angle: 0.0,
scaled: false,
},
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line {
fill: Some(Fill::None),
..Default::default()
}),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
for (x, want) in [(400, 96), (600, 175)] {
let got = img.pixel(x, 50)[0] as i32;
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
}
}
#[test]
fn a_shape_filled_with_the_background_shows_the_slide_background() {
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let none = || {
Some(deckcraft_model::style::Line {
fill: Some(Fill::None),
..Default::default()
})
};
let panel = Shape {
id: ShapeId(710),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(
255, 255, 255,
)))),
line: none(),
..Default::default()
};
let window = Shape {
id: ShapeId(711),
xfrm: Some(Xfrm::new(100.0, 100.0, 760.0, 340.0)),
fill: Some(Fill::Background),
line: none(),
..Default::default()
};
let mut p = deck_with(vec![panel, window]);
let stop = |pos, v| GradientStop {
pos,
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)),
};
std::sync::Arc::make_mut(&mut p.slides[0]).background =
Some(deckcraft_model::Background::Fill {
fill: Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
shape: GradientShape::Linear {
angle: 0.0,
scaled: false,
},
rotate_with_shape: true,
}),
});
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
assert_eq!(
img.pixel(50, 50)[0],
255,
"the panel stays white outside the window"
);
assert!(
img.pixel(300, 270)[0] < 8,
"the window shows the background's black half: {:?}",
img.pixel(300, 270)
);
let v = img.pixel(720, 270)[0] as i32;
assert!((v - 100).abs() <= 6, "{v}");
}
#[test]
fn a_background_is_laid_over_white() {
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 200])))
.write_to(&mut png, image::ImageFormat::Png)
.unwrap();
let mut p = deck_with(vec![]);
let media = p.add_media("bg.png", "image/png", png.into_inner());
let pf = PictureFill {
media,
mode: PictureMode::Stretch {
fill_rect: [0.5, 0.5, 0.0, 0.0],
},
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).background =
Some(deckcraft_model::Background::Fill {
fill: Fill::Picture(pf),
});
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 0.5,
..Default::default()
},
);
assert_eq!(
img.pixel(20, 20),
[255, 255, 255, 255],
"white where the picture doesn't reach"
);
let q = img.pixel(400, 230);
assert!(q[2] > 190 && q[0] < 10 && q[3] == 255, "{q:?}");
let see_through = ColorRef::rgb(deckcraft_color::Rgba {
r: 0,
g: 0,
b: 0,
a: 128,
});
std::sync::Arc::make_mut(&mut p.slides[0]).background =
Some(deckcraft_model::Background::Fill {
fill: Fill::solid(see_through),
});
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 0.5,
..Default::default()
},
);
let g = img.pixel(20, 20);
assert!(
g[3] == 255 && (g[0] as i32 - 127).abs() <= 3,
"half-transparent black over white is grey: {g:?}"
);
}
#[test]
fn colour_glyphs_paint_their_layers() {
deckcraft_fonts::FontDb::global()
.add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec());
let mut sh = Shape {
id: ShapeId(720),
xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)),
text_box: true,
..Default::default()
};
let mut t = TextBody::from_text("\u{E000} \u{E001}");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(72.0);
props.font = Some("DeckCraft Colr Squares".into());
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(
deckcraft_color::Rgba::rgb(0, 160, 0),
)));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let count = |f: &dyn Fn(&[u8; 4]) -> bool| {
img.pixels
.as_chunks::<4>()
.0
.iter()
.filter(|px| f(px))
.count()
};
let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30);
let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230);
let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30);
assert!(red > 2500 && blue > 500, "red {red} blue {blue}");
let share = blue as f64 / (red + blue) as f64;
assert!((share - 0.16).abs() < 0.03, "blue share {share}");
assert!(
(green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1,
"green {green} vs {}",
red + blue
);
}
#[test]
fn a_text_gradient_starts_over_on_every_line() {
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop {
pos,
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)),
};
let mut sh = Shape {
id: ShapeId(730),
xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)),
text_box: true,
..Default::default()
};
let mut t = TextBody::from_text("HHHH\nHHHH");
for p in &mut t.paragraphs {
let props = &mut p.runs[0].props;
props.size = Some(80.0);
props.fill = Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(1.0, 255)],
shape: GradientShape::Linear {
angle: 90.0,
scaled: false,
},
rotate_with_shape: true,
}));
}
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250);
let mut bands: Vec<(u32, u32)> = vec![];
for y in 0..500 {
if ink(y) {
match bands.last_mut() {
Some(b) if b.1 + 1 == y => b.1 = y,
_ => bands.push((y, y)),
}
}
}
assert_eq!(bands.len(), 2, "{bands:?}");
let top = |b: (u32, u32)| {
(b.0..b.0 + 6)
.flat_map(|y| (0..600).map(move |x| (x, y)))
.map(|(x, y)| img.pixel(x, y)[0])
.min()
.unwrap()
};
let (a, b) = (top(bands[0]), top(bands[1]));
assert!(
a < 140 && (a as i32 - b as i32).abs() < 25,
"line tops {a} and {b}: the second line starts dark again"
);
let bottom = |b: (u32, u32)| {
(b.1 - 5..=b.1)
.flat_map(|y| (0..600).map(move |x| (x, y)))
.map(|(x, y)| img.pixel(x, y)[0])
.min()
.unwrap()
};
for band in bands {
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
}
}
fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation {
let mut sh = Shape {
id: ShapeId(740),
xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)),
text_box: true,
..Default::default()
};
let mut t = TextBody::from_text("IIII");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(100.0);
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(
deckcraft_color::Rgba::rgb(0, 0, 0),
)));
set(props);
sh.text = Some(t);
deck_with(vec![sh])
}
#[test]
fn text_glow_and_shadow_are_drawn() {
let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| {
img.pixels
.as_chunks::<4>()
.0
.iter()
.filter(|px| f(px))
.count()
};
let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
let plain = effect_text(|_| {});
assert!(!needs_filters(&plain, &plain.slides[0]));
let img = render_slide(
&plain,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
assert_eq!(count(&img, &green), 0);
let glow = effect_text(|p| {
p.glow = Some(deckcraft_model::style::Glow {
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)),
radius: 8.0,
})
});
assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs");
let img = render_slide(
&glow,
0,
&RenderOpts {
scale: 1.0,
threads: 4,
..Default::default()
},
);
let halo = count(&img, &green);
assert!(halo > 300, "green halo pixels {halo}");
let shadow = effect_text(|p| {
p.shadow = Some(deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba {
r: 0,
g: 0,
b: 0,
a: 128,
}),
blur: 2.0,
dist: 12.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
})
});
let img = render_slide(
&shadow,
0,
&RenderOpts {
scale: 1.0,
threads: 4,
..Default::default()
},
);
let base = render_slide(
&plain,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2];
assert!(
count(&img, &grey) > count(&base, &grey) + 300,
"a grey shadow appears"
);
}
#[test]
fn pattern_fills_draw_powerpoints_tiles() {
use deckcraft_model::style::{Fill, PatternFill};
let rgb = |v| ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v));
let shape = |id, x: f64, preset: &str| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(x, 0.0, 60.0, 60.0)),
fill: Some(Fill::Pattern(PatternFill {
preset: preset.into(),
fg: rgb(0),
bg: rgb(255),
})),
line: Some(deckcraft_model::style::Line {
fill: Some(Fill::None),
..Default::default()
}),
..Default::default()
};
let p = deck_with(vec![shape(750, 0.0, "dkVert"), shape(751, 120.0, "pct50")]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 4.0 / 3.0,
..Default::default()
},
);
let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 128;
let row: Vec<bool> = (8..16).map(|x| dark(x, 20)).collect();
assert_eq!(
row,
[true, true, false, false, true, true, false, false],
"dkVert columns"
);
assert!(
(8..16).all(|y| dark(8, y) == dark(8, 20)),
"dkVert is vertical"
);
for (x, y) in [(168, 8), (169, 9), (170, 12)] {
assert_ne!(
dark(x, y),
dark(x + 1, y),
"pct50 alternates across at {x},{y}"
);
assert_eq!(
dark(x, y),
dark(x + 1, y + 1),
"pct50 repeats diagonally at {x},{y}"
);
}
}
#[test]
fn compound_lines_draw_their_bands() {
use deckcraft_model::style::{Compound, Fill, Line};
let runs = |c: Compound| {
let sh = Shape {
id: ShapeId(760),
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(
255, 255, 255,
)))),
line: Some(Line {
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(
0, 0, 0,
)))),
width: Some(24.0),
compound: Some(c),
..Default::default()
}),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 2.0,
..Default::default()
},
);
let mut out: Vec<(bool, f64)> = vec![];
for y in 160..240 {
let dark = img.pixel(500, y)[0] < 128;
match out.last_mut() {
Some((d, n)) if *d == dark => *n += 0.5,
_ => out.push((dark, 0.5)),
}
}
out.into_iter()
.filter(|(d, _)| *d)
.map(|(_, n)| n)
.collect::<Vec<f64>>()
};
let close = |got: Vec<f64>, want: &[f64]| {
got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0)
};
assert!(close(runs(Compound::Single), &[24.0]));
let d = runs(Compound::Double);
assert!(close(d.clone(), &[8.0, 8.0]), "{d:?}");
let t = runs(Compound::ThinThick);
assert!(close(t.clone(), &[4.8, 14.4]), "{t:?}");
let k = runs(Compound::ThickThin);
assert!(close(k.clone(), &[14.4, 4.8]), "{k:?}");
let tri = runs(Compound::Triple);
assert!(close(tri.clone(), &[4.0, 8.0, 4.0]), "{tri:?}");
}
#[test]
fn rect_path_gradients_draw_x_shaped_contours() {
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop {
pos,
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)),
};
let sh = Shape {
id: ShapeId(770),
xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
shape: GradientShape::Path {
path: "rect".into(),
focus: [0.5, 0.5, 0.5, 0.5],
},
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line {
fill: Some(Fill::None),
..Default::default()
}),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let v = |x: u32, y: u32| img.pixel(x, y)[0] as f64;
assert!((v(160, 100) - 255.0 / 7.0).abs() < 6.0, "{}", v(160, 100));
assert!((v(140, 120) - 51.0).abs() < 6.0, "{}", v(140, 120));
assert!((v(168, 120) - 51.0).abs() < 6.0, "{}", v(168, 120));
}
#[test]
fn soft_edges_fade_to_the_outline() {
let sh = Shape {
id: ShapeId(780),
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
geom: Geom::Preset {
name: "ellipse".into(),
adj: vec![],
},
fill: Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(
deckcraft_color::Rgba::rgb(0, 0, 0),
))),
line: Some(deckcraft_model::style::Line {
fill: Some(deckcraft_model::style::Fill::None),
..Default::default()
}),
effects: Some(deckcraft_model::Effects {
soft_edge: Some(12.0),
..Default::default()
}),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let alpha = |x: u32, y: u32| 1.0 - img.pixel(x, y)[0] as f64 / 255.0;
assert!(alpha(250, 101) < 0.08, "edge {}", alpha(250, 101));
let mid = alpha(250, 110);
assert!((0.3..0.7).contains(&mid), "0.85 r inside: {mid}");
assert!(alpha(250, 130) > 0.97, "inside {}", alpha(250, 130));
assert!(alpha(110, 105) < 0.01);
}
#[test]
fn a_file_defined_table_style_draws_its_parts() {
use deckcraft_model::style::{Fill, Line};
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
let mut t = Table::new(4, 2, 400.0, 50.0);
t.style = "{ABCDEF01-2345-4678-9ABC-DEF012345678}".into();
t.first_row = false;
t.band_row = true;
let line = |c: ColorRef, w| TableBorder::Line {
line: Line {
fill: Some(Fill::solid(c)),
width: Some(w),
..Default::default()
},
};
let mut whole = TablePartStyle {
fill: Some(Fill::None),
..Default::default()
};
whole.borders[0] = Some(line(rgb(0, 0, 255), 4.0));
whole.borders[4] = Some(TableBorder::Line {
line: Line {
fill: Some(Fill::None),
..Default::default()
},
});
let see_through = ColorRef::rgb(deckcraft_color::Rgba {
r: 255,
g: 255,
b: 255,
a: 51,
});
let band = TablePartStyle {
fill: Some(Fill::solid(see_through)),
..Default::default()
};
let def = TableStyleDef {
id: t.style.clone(),
name: "Probe".into(),
parts: vec![(TablePart::WholeTbl, whole), (TablePart::Band1H, band)],
background: Some(TablePartStyle {
fill: Some(Fill::solid(rgb(255, 0, 0))),
..Default::default()
}),
raw: String::new(),
};
let tbl = Shape {
id: ShapeId(790),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
kind: ShapeKind::Table(t),
..Default::default()
};
let mut p = deck_with(vec![tbl]);
p.table_styles.push(def);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let px = |x, y| img.pixel(x, y);
let banded = px(300, 125);
assert!(
banded[0] > 250 && (40..65).contains(&banded[1]),
"{banded:?}"
);
assert_eq!(px(300, 175)[..3], [255, 0, 0]);
let edge = px(300, 150);
assert!(edge[0] > 240, "no inside line: {edge:?}");
let left = px(100, 175);
assert!(left[2] > 200 && left[0] < 60, "{left:?}");
}
#[test]
fn a_table_fill_shows_behind_cells_without_their_own() {
use deckcraft_model::style::Fill;
let mut t = Table::new(2, 2, 200.0, 50.0);
t.style = "none".into();
t.background = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(
0, 112, 192,
))));
for r in 0..2 {
for c in 0..2 {
t.cell_mut(r, c).unwrap().fill = Some(Fill::None);
}
}
let tbl = Shape {
id: ShapeId(795),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
kind: ShapeKind::Table(t),
..Default::default()
};
let p = deck_with(vec![tbl]);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]);
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
}
fn effect_box(effects: deckcraft_model::Effects) -> Image {
use deckcraft_model::style::{Fill, Line};
let sh = Shape {
id: ShapeId(800),
xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(
255, 255, 255,
)))),
line: Some(Line {
fill: Some(Fill::None),
..Default::default()
}),
effects: Some(effects),
..Default::default()
};
render_slide(
&deck_with(vec![sh]),
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
)
}
fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow {
deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)),
blur,
dist,
dir,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}
}
#[test]
fn shadows_and_glows_spread_like_powerpoints() {
let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32;
let img = effect_box(deckcraft_model::Effects {
outer_shadow: Some(shadow(9.0, 0.0, 0.0)),
..Default::default()
});
for (d, want) in [(1, 144), (3, 204), (5, 238)] {
assert!(
(red(&img, 20 - d, 120) - want).abs() <= 6,
"outer {d}: {}",
red(&img, 20 - d, 120)
);
}
let mut sh = shadow(0.0, 10.0, 45.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects {
inner_shadow: Some(sh),
..Default::default()
});
assert!(
red(&img, 296, 120) < 20,
"right edge {}",
red(&img, 296, 120)
);
assert!(
red(&img, 160, 216) < 20,
"bottom edge {}",
red(&img, 160, 216)
);
assert!(
red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250,
"top and left stay clear"
);
let mut sh = shadow(9.0, 0.0, 0.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects {
inner_shadow: Some(sh),
..Default::default()
});
for (d, want) in [(0, 144), (2, 204), (4, 238)] {
assert!(
(red(&img, 20 + d, 120) - want).abs() <= 6,
"inner {d}: {}",
red(&img, 20 + d, 120)
);
}
let glow = deckcraft_model::style::Glow {
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)),
radius: 10.0,
};
let img = effect_box(deckcraft_model::Effects {
glow: Some(glow),
..Default::default()
});
assert!(red(&img, 19, 120) < 20);
assert!(
(60..170).contains(&red(&img, 15, 120)),
"{}",
red(&img, 15, 120)
);
assert!(red(&img, 10, 120) > 245);
}
fn wordart(warp: Option<&str>, text: &str) -> Presentation {
let mut t = TextBody::from_text(text);
t.paragraphs[0].runs[0].props.size = Some(40.0);
t.paragraphs[0].props.align = Some(deckcraft_model::text::Align::Center);
t.body.warp = warp.map(String::from);
deck_with(vec![Shape {
id: ShapeId(700),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
text: Some(t),
..Default::default()
}])
}
fn ink(img: &Image) -> ((u32, u32, u32, u32), Vec<Option<u32>>) {
let mut b = (u32::MAX, u32::MAX, 0, 0);
let mut tops = vec![None; img.width as usize];
for y in 0..img.height {
for x in 0..img.width {
if img.pixel(x, y)[0] < 128 {
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
if tops[x as usize].is_none() {
tops[x as usize] = Some(y);
}
}
}
}
(b, tops)
}
#[test]
fn wordart_arch_up_lifts_the_middle_of_the_line() {
let o = RenderOpts {
scale: 1.0,
..Default::default()
};
let flat = render_slide(&wordart(None, "ARCHED WORDART"), 0, &o);
let arched = render_slide(&wordart(Some("textArchUp"), "ARCHED WORDART"), 0, &o);
assert!(diff_px(&flat, &arched) > 1000);
let ((fx0, _, fx1, _), ftops) = ink(&flat);
let ((ax0, ay0, ax1, _), atops) = ink(&arched);
let top_near = |tops: &[Option<u32>], x: u32| {
(x.saturating_sub(15)..x + 15)
.filter_map(|i| tops.get(i as usize).copied().flatten())
.min()
};
let (fl, fm) = (
top_near(&ftops, fx0 + 20).unwrap(),
top_near(&ftops, (fx0 + fx1) / 2).unwrap(),
);
assert!(fl.abs_diff(fm) < 4, "flat {fl} {fm}");
let (al, am, ar) = (
top_near(&atops, ax0 + 20).unwrap(),
top_near(&atops, (ax0 + ax1) / 2).unwrap(),
top_near(&atops, ax1 - 20).unwrap(),
);
assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}");
assert!((70..=110).contains(&ay0), "arch top {ay0}");
}
#[test]
fn wordart_plain_stretches_the_ink_over_the_text_rect() {
let img = render_slide(
&wordart(Some("textPlain"), "PLAIN"),
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let ((x0, y0, x1, y1), _) = ink(&img);
for (got, want) in [(x0, 100), (y0, 100), (x1, 499), (y1, 299)] {
assert!(got.abs_diff(want) <= 2, "ink box {:?}", (x0, y0, x1, y1));
}
}
#[test]
fn unknown_or_absent_warps_draw_flat_text() {
let o = RenderOpts {
scale: 0.5,
..Default::default()
};
let _ = render_slide(&wordart(None, "FLAT"), 0, &o);
let flat = render_slide(&wordart(None, "FLAT"), 0, &o);
for w in ["textNoShape", "bogus", ""] {
assert_eq!(
diff_px(&flat, &render_slide(&wordart(Some(w), "FLAT"), 0, &o)),
0,
"{w}"
);
}
}
#[test]
fn every_warp_preset_renders_text_inside_the_slide() {
let o = RenderOpts {
scale: 0.5,
..Default::default()
};
for name in [
"textArchUp",
"textArchDown",
"textCircle",
"textButton",
"textWave1",
"textRingInside",
"textCirclePour",
"textDeflateInflateDeflate",
] {
let mut p = wordart(Some(name), "One line\nTwo line\nThree");
if let Some(b) = std::sync::Arc::make_mut(&mut p.slides[0]).shapes[0]
.text
.as_mut()
{
b.body.warp_adj = vec![f64::NAN, -1e12];
}
let img = render_slide(&p, 0, &o);
let ((x0, y0, x1, y1), _) = ink(&img);
assert!(x0 <= x1 && y0 <= y1, "{name}: no ink");
}
}
fn half_red_wmf() -> Vec<u8> {
fn rec(func: u16, params: &[i16]) -> Vec<u8> {
let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec();
v.extend(func.to_le_bytes());
for p in params {
v.extend(p.to_le_bytes());
}
v
}
let body = [
rec(0x02FC, &[0, 0xFF, 0, 0]),
rec(0x012D, &[0]),
rec(0x041B, &[1440, 1440, 0, 0]),
rec(0, &[]),
]
.concat();
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
f.extend(0u16.to_le_bytes());
for v in [0i16, 0, 2880, 1440] {
f.extend(v.to_le_bytes());
}
f.extend(1440u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
let total = (f.len() + 18 + body.len()) / 2;
for v in [1u16, 9, 0x300] {
f.extend(v.to_le_bytes());
}
f.extend((total as u32).to_le_bytes());
f.extend(16u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
f.extend(body);
f
}
#[test]
fn metafile_pictures_draw_their_vectors() {
let mut p = Presentation::default();
let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf());
let fill = deckcraft_model::style::PictureFill {
media,
..Default::default()
};
let sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
kind: ShapeKind::Picture { fill },
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
for scale in [0.5, 1.0, 3.0] {
let img = render_slide(
&p,
0,
&RenderOpts {
scale,
..Default::default()
},
);
let at = |x: f64, y: f64| img.pixel((x * scale) as u32, (y * scale) as u32);
let left = at(200.0, 200.0);
assert!(
left[0] > 200 && left[1] < 60 && left[2] < 60,
"left half red at {scale}: {left:?}"
);
let right = at(400.0, 200.0);
assert!(
right[0] > 200 && right[1] > 200 && right[2] > 200,
"right half empty at {scale}: {right:?}"
);
}
}
#[test]
fn broken_metafiles_draw_nothing_and_never_panic() {
let wmf = half_red_wmf();
let mut cases: Vec<Vec<u8>> = (0..wmf.len())
.step_by(3)
.map(|n| wmf[..n].to_vec())
.collect();
cases.push(vec![1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF]);
let mut huge = wmf.clone();
huge[10] = 0xFF;
huge[11] = 0x7F;
cases.push(huge);
for bytes in cases {
let mut p = Presentation::default();
let media = p.add_media("image1.emf", "image/x-emf", bytes);
let fill = deckcraft_model::style::PictureFill {
media,
..Default::default()
};
let sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)),
kind: ShapeKind::Picture { fill },
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let _ = render_slide(
&p,
0,
&RenderOpts {
scale: 0.25,
..Default::default()
},
);
}
}
#[test]
fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() {
let png = {
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
4,
2,
image::Rgb([200, 20, 20]),
))
.write_to(&mut out, image::ImageFormat::Png)
.unwrap();
out.into_inner()
};
for with_preview in [true, false] {
let mut p = Presentation::default();
let media = p.add_media("image1.png", "image/png", png.clone());
let sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
kind: ShapeKind::Opaque {
xml: String::new(),
preview: with_preview.then_some(media),
label: "Package".into(),
},
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
for (x, y) in [(110, 110), (300, 200), (490, 290)] {
let px = img.pixel(x, y);
let red = px[0] > 180 && px[1] < 60 && px[2] < 60;
assert_eq!(red, with_preview, "({x}, {y}) {px:?}");
}
assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame");
}
}
fn off_slide_deck() -> Presentation {
let half = Shape {
id: ShapeId(2700),
xfrm: Some(Xfrm::new(-100.0, 100.0, 200.0, 100.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let below = Shape {
id: ShapeId(2701),
xfrm: Some(Xfrm::new(400.0, 600.0, 100.0, 60.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
..Default::default()
};
deck_with(vec![half, below])
}
fn near(px: [u8; 4], c: deckcraft_color::Rgba) -> bool {
(px[0] as i32 - c.r as i32).abs() <= 3
&& (px[1] as i32 - c.g as i32).abs() <= 3
&& (px[2] as i32 - c.b as i32).abs() <= 3
&& px[3] == 255
}
#[test]
fn shows_exports_and_thumbnails_clip_to_the_slide() {
let p = off_slide_deck();
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
assert_eq!((img.width, img.height), (960, 540));
let a1 = p.masters[0]
.theme
.colors
.get(deckcraft_color::SchemeSlot::Accent1);
assert!(near(img.pixel(50, 150), a1), "{:?}", img.pixel(50, 150));
assert_eq!(img.pixel(150, 150), [255, 255, 255, 255]);
}
#[test]
fn the_editor_pasteboard_draws_shapes_off_the_slide() {
let p = off_slide_deck();
let extent = slide_extent(&p, 0);
let region = pasteboard_region(
(960, 540),
1.0,
extent,
Region {
x: -300,
y: -200,
w: 1560,
h: 1000,
},
);
assert!(
region.x <= -100 && region.y == 0 && region.y as i64 + region.h as i64 >= 660,
"{region:?}"
);
let opts = RenderOpts {
scale: 1.0,
edit: true,
offset: region.offset(),
size: Some(region.size()),
..Default::default()
};
let (img, drawn) = render_slide_drawn(&p, 0, &opts);
assert_eq!((img.width, img.height), region.size());
let at = |x: i32, y: i32| img.pixel((x - region.x) as u32, (y - region.y) as u32);
let a1 = p.masters[0]
.theme
.colors
.get(deckcraft_color::SchemeSlot::Accent1);
let a2 = p.masters[0]
.theme
.colors
.get(deckcraft_color::SchemeSlot::Accent2);
assert!(near(at(-50, 150), a1), "{:?}", at(-50, 150));
assert!(near(at(50, 150), a1), "{:?}", at(50, 150));
assert!(near(at(450, 630), a2), "{:?}", at(450, 630));
assert_eq!(at(-50, 50), [0, 0, 0, 0]);
assert_eq!(at(200, 300), [255, 255, 255, 255]);
assert!(
drawn.x0 <= -100.0 && drawn.y1 >= 660.0 && drawn.x1 >= 960.0,
"{drawn:?}"
);
}
#[test]
fn master_graphics_off_the_slide_show_on_the_pasteboard() {
let mut p = Presentation::default();
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
m.shapes.push(Shape {
id: ShapeId(2702),
xfrm: Some(Xfrm::new(-150.0, 300.0, 100.0, 60.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent3)),
..Default::default()
});
let region = pasteboard_region(
(960, 540),
1.0,
slide_extent(&p, 0),
Region {
x: -300,
y: -100,
w: 1500,
h: 800,
},
);
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
edit: true,
offset: region.offset(),
size: Some(region.size()),
..Default::default()
},
);
let a3 = p.masters[0]
.theme
.colors
.get(deckcraft_color::SchemeSlot::Accent3);
assert!(near(
img.pixel((-100 - region.x) as u32, (330 - region.y) as u32),
a3
));
}
#[test]
fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
let mut sh = Shape {
id: ShapeId(2703),
xfrm: Some(Xfrm::new(100.0, 500.0, 400.0, 20.0)),
..Default::default()
};
let mut t = TextBody::from_text("one\ntwo\nthree\nfour\nfive");
for para in &mut t.paragraphs {
for r in &mut para.runs {
r.props.size = Some(40.0);
}
}
sh.text = Some(t);
let p = deck_with(vec![sh]);
assert!(slide_extent(&p, 0).y1 <= 540.0 + 4.0);
let (_, drawn) = render_slide_drawn(
&p,
0,
&RenderOpts {
scale: 0.5,
..Default::default()
},
);
assert!(drawn.y1 > 600.0, "{drawn:?}");
let (_, drawn) = render_slide_drawn(
&Presentation::default(),
0,
&RenderOpts {
scale: 0.5,
edit: true,
..Default::default()
},
);
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
}
fn wmf_with(w: i16, h: i16, records: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut body = Vec::new();
for (func, params) in records {
let mut p = params.clone();
if p.len() % 2 == 1 {
p.push(0);
}
body.extend(((6 + p.len()) as u32 / 2).to_le_bytes());
body.extend(func.to_le_bytes());
body.extend(p);
}
body.extend([3, 0, 0, 0, 0, 0]);
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
f.extend(0u16.to_le_bytes());
for v in [0i16, 0, w, h] {
f.extend(v.to_le_bytes());
}
f.extend(1440u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
let total = (f.len() + 18 + body.len()) / 2;
for v in [1u16, 9, 0x300] {
f.extend(v.to_le_bytes());
}
f.extend((total as u32).to_le_bytes());
f.extend(16u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
f.extend(body);
f
}
fn i16s(v: &[i32]) -> Vec<u8> {
v.iter().flat_map(|x| (*x as i16).to_le_bytes()).collect()
}
fn font_records(height: i32, esc: i32, face: &str, ta: i32) -> Vec<(u16, Vec<u8>)> {
let mut f = i16s(&[height, 0, esc, esc, 400]);
f.extend([
0,
0,
0,
if face.contains("dings") { 2 } else { 0 },
0,
0,
0,
0,
]);
f.extend(face.as_bytes());
f.push(0);
vec![
(0x02FB, f),
(0x012D, i16s(&[0])),
(0x0102, i16s(&[1])),
(0x012E, i16s(&[ta])),
]
}
fn text_record(x: i32, y: i32, s: &str, dx: &[i32]) -> (u16, Vec<u8>) {
let mut v = i16s(&[y, x, s.len() as i32, 0]);
v.extend(s.as_bytes());
if s.len() % 2 == 1 {
v.push(0);
}
v.extend(i16s(dx));
(0x0A32, v)
}
fn mf_ink(img: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> {
let mut b: Option<(u32, u32, u32, u32)> = None;
for (x, y, p) in img.enumerate_pixels() {
if p[3] > 128 && (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 {
b = Some(match b {
None => (x, y, x, y),
Some((a, c, d, e)) => (a.min(x), c.min(y), d.max(x), e.max(y)),
});
}
}
b
}
#[test]
fn metafile_text_ink_lands_at_its_reference_point() {
let mut recs = font_records(-720, 0, "Arial", 0);
recs.push(text_record(1440, 360, "HH", &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let (x0, y0, x1, y1) = mf_ink(&img).expect("text ink");
assert!(
(144..156).contains(&x0),
"left edge at the reference point: {x0}"
);
assert!(x1 > x0 + 40 && x1 < 288, "two capitals wide: {x1}");
assert!((38..72).contains(&y0), "cap top below the cell top: {y0}");
assert!(
(88..112).contains(&y1),
"baseline one ascent below the top: {y1}"
);
}
#[test]
fn metafile_text_centre_baseline_and_escapement() {
let mut recs = font_records(-720, 0, "Arial", 24 | 6);
recs.push(text_record(1440, 720, "HH", &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let (x0, _, x1, y1) = mf_ink(&img).expect("ink");
assert!(
((x0 + x1) as i32 / 2 - 144).abs() <= 4,
"centred: {x0}..{x1}"
);
assert!((69..=74).contains(&y1), "on the baseline: {y1}");
let mut recs = font_records(-360, 900, "Arial", 24);
recs.push(text_record(1440, 1300, "HHHH", &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
assert!(
y1 - y0 > 2 * (x1 - x0),
"a vertical run: {x0} {y0} {x1} {y1}"
);
assert!(
x1 <= 146 && y1 <= 132,
"drawn up and left of the baseline origin (144, 130): {x1} {y1}"
);
}
#[test]
fn metafile_text_follows_its_dx_array() {
let mut recs = font_records(-360, 0, "Arial", 24);
recs.push(text_record(360, 720, "HH", &[1440, 100]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let col_has_ink = |x: u32| (0..img.height()).any(|y| img.get_pixel(x, y)[3] > 128);
assert!((36..56).any(col_has_ink), "first H at 36 px");
assert!((180..200).any(col_has_ink), "second H one inch later");
assert!(!(70..170).any(col_has_ink), "nothing between them");
}
#[test]
fn metafile_symbol_text_draws_a_bullet_not_a_box() {
for (face, s) in [("Webdings", "n"), ("Wingdings Not Installed", "l")] {
let mut recs = font_records(-1080, 0, face, 0);
recs.push(text_record(360, 0, s, &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
assert!(
x1 - x0 > 10 && y1 - y0 > 10,
"{face}: a sizeable mark: {x0} {y0} {x1} {y1}"
);
let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);
assert!(
img.get_pixel(cx, cy)[3] > 200,
"{face}: filled in the middle"
);
for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] {
assert!(
img.get_pixel(x, y)[3] < 128,
"{face}: round, not a box: corner ({x}, {y})"
);
}
}
}
#[test]
fn metafile_opaque_text_fills_its_cell() {
let mut recs = font_records(-720, 0, "Arial", 0);
recs.push((0x0102, i16s(&[2])));
recs.push((0x0201, 0x0000_00FFu32.to_le_bytes().to_vec()));
recs.push(text_record(1440, 360, " ", &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let p = img.get_pixel(148, 60);
assert!(
p[0] > 200 && p[1] < 60 && p[3] > 200,
"cell filled red: {p:?}"
);
assert!(img.get_pixel(140, 60)[3] < 50, "nothing left of the cell");
}
#[test]
fn metafile_pattern_brush_tiles_and_averages_when_tiny() {
let mut dib: Vec<u8> = [40u32, 8, 8].iter().flat_map(|v| v.to_le_bytes()).collect();
dib.extend(1u16.to_le_bytes());
dib.extend(1u16.to_le_bytes());
dib.extend([0u8; 24]);
dib.extend([255, 255, 255, 0, 0, 0, 0, 0]);
for r in 0..8 {
dib.extend([if r % 2 == 0 { 0x55 } else { 0xAA }, 0, 0, 0]);
}
let mut brush = i16s(&[5, 0]);
brush.extend(dib);
let recs = vec![
(0x0142, brush),
(0x012D, i16s(&[0])),
(0x012D, i16s(&[0x8008])),
(0x041B, i16s(&[1440, 1440, 0, 0])),
];
let bytes = wmf_with(1440, 1440, &recs);
let img = crate::metafile::rasterize(&bytes, 288).expect("raster");
let (a, b) = (img.get_pixel(1, 1), img.get_pixel(4, 1));
assert!(a[3] > 200 && b[3] > 200);
assert!(
(a[0] as i32 - b[0] as i32).abs() > 200,
"neighbouring cells alternate: {a:?} {b:?}"
);
let img = crate::metafile::rasterize(&bytes, 32).expect("raster");
let p = img.get_pixel(16, 16);
assert!(
(100..156).contains(&p[0]) && p[3] > 200,
"average grey: {p:?}"
);
}
fn outlined_group(child: Xfrm, to_child: impl Fn(f64, f64) -> (f64, f64)) -> Image {
use deckcraft_model::style::{Dash, LineEnd};
let ink = |w: f64| deckcraft_model::style::Line {
dash: None,
..deckcraft_model::style::Line::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)), w)
};
let place = |x0: f64, y0: f64, x1: f64, y1: f64| {
let ((a, b), (c, d)) = (to_child(x0, y0), to_child(x1, y1));
Xfrm::new(a, b, c - a, d - b)
};
let rect = Shape {
id: ShapeId(11),
xfrm: Some(place(140.0, 120.0, 300.0, 200.0)),
geom: Geom::preset("rect"),
line: Some(deckcraft_model::style::Line {
dash: Some(Dash::Preset {
name: "dash".into(),
}),
..ink(4.0)
}),
..Default::default()
};
let end = LineEnd {
kind: "triangle".into(),
w: "lg".into(),
len: "lg".into(),
};
let line = Shape {
id: ShapeId(12),
xfrm: Some(Xfrm {
flip_v: true,
..place(150.0, 170.0, 450.0, 260.0)
}),
geom: Geom::preset("line"),
line: Some(deckcraft_model::style::Line {
tail: Some(end),
..ink(3.0)
}),
..Default::default()
};
let pts: Vec<(f64, f64, f32)> = [(160.0, 240.0), (260.0, 285.0)]
.iter()
.map(|(x, y)| to_child(*x, *y))
.map(|(x, y)| (x, y, 1.0))
.collect();
let ink = Shape {
id: ShapeId(13),
xfrm: Some(place(100.0, 100.0, 500.0, 300.0)),
kind: ShapeKind::Ink {
strokes: vec![deckcraft_model::InkStroke {
points: pts,
color: Rgba::rgb(200, 0, 0),
width: 5.0,
highlighter: false,
}],
},
..Default::default()
};
let group = Shape {
id: ShapeId(10),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
kind: ShapeKind::Group {
children: vec![rect, line, ink],
child,
},
..Default::default()
};
render_slide(
&deck_with(vec![group]),
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
)
}
fn inked(img: &Image) -> usize {
img.pixels
.as_chunks::<4>()
.0
.iter()
.filter(|px| px[1] < 200)
.count()
}
#[test]
fn group_child_scale_does_not_scale_line_widths() {
let reference = outlined_group(Xfrm::new(100.0, 100.0, 400.0, 200.0), |x, y| (x, y));
let drawn = inked(&reference);
assert!(
(500..20_000).contains(&drawn),
"the reference draws thin lines: {drawn}"
);
let uniform = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 2.0), |x, y| {
(1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 100.0)
});
let uneven = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 4.0), |x, y| {
(1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 50.0)
});
for (name, img) in [("uniform", &uniform), ("uneven", &uneven)] {
let (n, d) = (inked(img), differing(&reference, img));
assert!(
n.abs_diff(drawn) * 20 < drawn,
"{name}: {n} inked pixels against {drawn}"
);
assert!(
d * 20 < drawn,
"{name}: {d} differing values against {drawn} inked pixels"
);
}
}
#[test]
fn a_picture_casts_its_shadow_from_its_pixels() {
let png = {
let img = image::RgbaImage::from_fn(8, 4, |x, _| {
if x < 4 {
image::Rgba([0, 0, 0, 0])
} else {
image::Rgba([20, 160, 20, 255])
}
});
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgba8(img)
.write_to(&mut out, image::ImageFormat::Png)
.unwrap();
out.into_inner()
};
let shadow = deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
blur: 0.0,
dist: 20.0,
dir: 90.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
};
for as_fill in [false, true] {
let mut p = Presentation::default();
let media = p.add_media("logo.png", "image/png", png.clone());
let pf = deckcraft_model::style::PictureFill {
media,
..Default::default()
};
let mut sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
..Default::default()
};
if as_fill {
sh.fill = Some(Fill::Picture(pf));
sh.line = Some(deckcraft_model::style::Line {
fill: Some(Fill::None),
..Default::default()
});
} else {
sh.kind = ShapeKind::Picture { fill: pf };
}
sh.effects = Some(deckcraft_model::Effects {
outer_shadow: Some(shadow.clone()),
..Default::default()
});
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
let left = img.pixel(200, 310);
let right = img.pixel(400, 310);
assert!(
left[0] > 240,
"no shadow under the transparent half ({as_fill}): {left:?}"
);
assert!(
right[0] < 40,
"shadow under the opaque half ({as_fill}): {right:?}"
);
assert!(img.pixel(200, 200)[0] > 240, "{as_fill}");
}
}
#[test]
fn text_in_a_scaled_group_keeps_its_point_size() {
let draw = |scale: f64| {
let mut p = Presentation::default();
let mut body = TextBody::from_text("Hello");
body.paragraphs[0].runs[0].props.size = Some(24.0);
body.paragraphs[0].runs[0].props.fill =
Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK)));
let child = Shape {
id: ShapeId(601),
xfrm: Some(Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale)),
text: Some(body),
..Default::default()
};
let g = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
kind: ShapeKind::Group {
children: vec![child],
child: Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale),
},
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![g];
render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
)
};
let ((_, y0, _, y1), _) = ink(&draw(1.0));
let plain = y1 - y0;
assert!(plain > 10 && plain < 30, "24 pt word ink height {plain}");
for scale in [1600.0, 4.0, 0.25] {
let ((x0, sy0, x1, sy1), _) = ink(&draw(scale));
assert!(
(sy1 - sy0).abs_diff(plain) <= 1,
"scale {scale}: ink height {} vs {plain}",
sy1 - sy0
);
assert!(
x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300,
"scale {scale}: ink inside the box"
);
}
}
#[test]
fn a_fill_overlay_blends_over_the_fill() {
use deckcraft_model::style::FillOverlay;
let draw = |base: [u8; 3], blend: &str| {
let mut p = Presentation::default();
let mut sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(
base[0], base[1], base[2],
)))),
line: Some(deckcraft_model::style::Line {
fill: Some(Fill::None),
..Default::default()
}),
..Default::default()
};
sh.effects = Some(deckcraft_model::Effects {
fill_overlay: Some(FillOverlay {
blend: blend.into(),
fill: Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(128, 128, 128))),
}),
..Default::default()
});
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(
&p,
0,
&RenderOpts {
scale: 1.0,
..Default::default()
},
);
(img.pixel(200, 150), img.pixel(50, 50))
};
let (mult, outside) = draw([200, 100, 50], "mult");
assert!(
(mult[0] as i32 - 100).abs() <= 3
&& (mult[1] as i32 - 50).abs() <= 3
&& (mult[2] as i32 - 25).abs() <= 3,
"{mult:?}"
);
assert_eq!(&outside[..3], &[255, 255, 255], "nothing outside the shape");
let (screen, _) = draw([0, 0, 0], "screen");
assert!((screen[0] as i32 - 128).abs() <= 3, "{screen:?}");
let (over, _) = draw([200, 100, 50], "over");
assert!(
(over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3,
"{over:?}"
);
}