deckcraft/crates/render/src/tests.rs

1714 lines
83 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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() });
// Shadow darkens below-right of the shape (grey, past the glow), blurred by a third of its
// 8 pt blur radius.
let px = img.pixel(255, 155);
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
}
#[test]
fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
// Columns in the hundreds of thousands, a green line in the thousands on the secondary axis:
// the line must sit up in the plot, not flat along the bottom of the primary scale.
let mut c = Chart::sample(ChartType::Combo);
c.title = None;
c.legend = None;
c.series.truncate(2);
c.categories = vec!["A".into(), "B".into(), "C".into()];
c.series[0].kind = Some(ChartType::Column);
c.series[0].values = vec![Some(200_000.0), Some(300_000.0), Some(250_000.0)];
c.series[1].kind = Some(ChartType::Line);
c.series[1].values = vec![Some(7_000.0), Some(7_000.0), Some(7_000.0)];
c.series[1].secondary_axis = true;
c.series[1].line = Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 80)), 3.0));
let sh = Shape { id: ShapeId(620), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
let green = |x: u32, y0: u32, y1: u32| {
(y0..y1).any(|y| {
let p = img.pixel(x, y);
p[1] > 120 && p[0] < 80 && p[2] < 140
})
};
// The line is in the upper half (7 000 of an axis to 8 000), and columns fill the plot's bottom.
assert!(green(480, 20, 270), "line not in the upper half");
assert!(!green(480, 400, 500), "line along the bottom");
let col = img.pixel(480, 480);
assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}");
}
#[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() {
// pandoc and python-pptx write `<a:tr h="0">`: the stored row height is only a minimum.
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:?}");
// A taller stored height is kept.
let mut tall = t;
tall.rows[1].height = 200.0;
assert_eq!(crate::table::row_heights(&ctx, &tall, &deckcraft_text::NoFields)[1], 200.0);
// The PDF text layer gets every cell's text, rows one below the other.
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)];
// At the ends the frame is the slide itself; the shape stays opaque throughout.
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));
// Hostile input: bad indices and NaN never panic.
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)
};
// Warm up: faces load lazily (a background scan runs), so the first render may still fall back.
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}");
// Halfway the shared words are on their way: ink between the two texts' rows.
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}");
}
/// A text box holding one run of `kind` (a field type, or plain text for `None`) saved as `saved`.
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); // faces load lazily
// The field shows the date (it changes with the clock), not its saved text.
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");
// Another slide's fixed date (a deck-wide Header & Footer text) doesn't replace it.
p.header_footer.date_text = "Fixed on another slide".into();
assert_eq!(diff_px(&a, &at(&p, fri)), 0);
// Every type has its format; the run's language orders numeric dates.
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"));
// A date the app can't compute (no clock, unknown type, other-language names) keeps the saved text.
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);
// An explicit override still wins.
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"));
}
/// Channel values that differ noticeably between two images of the same size.
fn diff_px(a: &Image, b: &Image) -> usize {
a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count()
}
/// Two 8×8 frames, red then blue, 100 ms each.
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: &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)
};
// The morph splits the outlines into more curves, which flatten slightly differently: edge pixels
// may differ a little, a different shape differs a lot.
let differ = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 60).count();
// At the start the star is still the circle, at the end the star.
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}");
// Without the morph the start would show the star.
let star = render_blend(&p, 0, 1, 0.0, &[(form(10, "star5", from), false)], &[], &[], &o);
assert!(differ(&star, &render_slide(&p, 0, &o)) > 400);
// Halfway the shape is filled around the middle of its box.
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 };
// A point near the line but not on it: inside the shape all the way.
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:?}");
// Halfway the fill is half there: between the full colour and the white slide.
assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}");
}
#[test]
fn text_transform_unmirrors_flips() {
// Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text.
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);
// flipH: the rect moves to 60..90 and the text still runs left to right.
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)));
// flipV: the text is turned 180° in its (vertically mirrored) rect.
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)));
// Both flips: the same as a 180° turn.
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)));
}
// Unflipped and hostile input: plain transform, nothing NaN from the helper itself.
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);
}
/// Slide with text box `x` holding left-aligned, asymmetric text.
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);
// Warm up: faces load lazily, so the first render may still fall back.
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");
// flipH: text as unflipped (the text box is symmetric). flipV and both: turned 180°.
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}");
// Rotation then flipH: still the rotated text, not its mirror image.
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}");
// Groups: a flipped group's text isn't mirrored; a flipped child in a flipped group neither.
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}");
}
/// A text box with one run of `props` (40 pt).
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())); // faces load lazily
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");
// Exactly the theme's hyperlink colour, single underline.
let styled = px(&run_deck(RunProps { fill: hlink, underline: Some("sng".into()), ..Default::default() }));
assert_eq!(diff(&linked, &styled), 0);
// A colour on the run itself wins; u="none" drops the underline.
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() {
// Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way
// (linear-light mix, S-shaped), not 64 and 128.
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 {
tile: [0.0; 4],
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() {
// Stops at 20 % and 100 %: PowerPoint blends linearly in sRGB (96 at 50 %, 175 at 75 %).
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 {
tile: [0.0; 4],
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() {
// `useBgFill`: PowerPoint paints the slide's background into the shape, laid out on the
// slide. A white panel covers a black-to-grey background; a shape on it with the background
// fill is a window onto that 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 {
tile: [0.0; 4],
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));
// Laid out on the slide: three quarters across the slide is half way from black to grey.
let v = img.pixel(720, 270)[0] as i32;
assert!((v - 100).abs() <= 6, "{v}");
}
#[test]
fn a_background_is_laid_over_white() {
// A background picture stretched into the bottom-right quarter (fillRect l=50% t=50%), and a
// half-transparent colour: PowerPoint shows white around and under them, not transparency.
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() {
// A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue
// square in its middle, U+E001 a square in the text colour.
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);
// 72 pt squares: the blue one is 40 % of the side (16 % of the area).
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}");
// The text-colour glyph is as big as the other square.
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() {
// PowerPoint fills each line of gradient text with the whole gradient, spanning that line
// (black at the top of every line to white at its bottom), not the text box.
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 {
tile: [0.0; 4],
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() });
// Ink rows (any pixel not white), grouped into the two lines.
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:?}");
// The darkest ink in the first rows of each line.
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");
// ...and each line ends light.
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));
}
}
/// A text box of big black "IIII" with `set` applied to its run.
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);
// Glow: a green halo hugging the letters, needing the blur filter.
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}");
// Shadow: grey ink below and right of the letters, where the plain text has none.
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() {
// dkVert at one output pixel per pattern cell (0.75 pt): columns 11001100… across, as
// PowerPoint's export draws it; pct50 is a checkerboard.
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");
// pct50 starts at slide x = 120 pt = 160 px.
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() {
// A 24 pt outline on a rectangle, as PowerPoint's export draws each kind (top edge, outside
// first): double 8/8/8, thin-thick 4.8/4.8/14.4, triple 4/4/8/4/4.
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() });
// Down the middle of the top edge (y 100 pt), from 80 pt to 120 pt: lengths of dark and
// light runs in points.
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() {
// path="rect" from the centre, black to grey to white (linear): PowerPoint's colour position is
// the larger axis share, so 20 pt right of centre in a 280x200 box is 1/7 and 20 pt down 1/5.
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 {
tile: [0.0; 4],
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));
// Along the diagonal to a corner the colour follows the vertical share.
assert!((v(168, 120) - 51.0).abs() < 6.0, "{}", v(168, 120));
}
#[test]
fn soft_edges_fade_to_the_outline() {
// An ellipse with a 12 pt soft edge: transparent at its outline, half opaque about 10 pt
// inside, opaque further in, as in PowerPoint's export (the fade follows the curve, not the
// bounding box).
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;
// Top of the ellipse is at y = 100, x = 250.
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));
// Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn.
assert!(alpha(110, 105) < 0.01);
}
#[test]
fn a_file_defined_table_style_draws_its_parts() {
// Red table background (tblBg), see-through white on banded rows, a 4 pt blue left border and
// no inside lines: drawn from the definition, not DeckCraft's own look for the style.
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);
// Row 1 (band1H): red with 20 % white over it; row 2 (band2H, undefined): plain red.
let banded = px(300, 125);
assert!(banded[0] > 250 && (40..65).contains(&banded[1]), "{banded:?}");
assert_eq!(px(300, 175)[..3], [255, 0, 0]);
// No line between the rows; a blue left border.
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() {
// `a:tblPr` solidFill (tdf#127237): cells with no fill show the table's blue.
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]);
}
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
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;
// Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5
// px outside the left edge.
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));
}
// Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right
// edges, not the top and left.
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");
// Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside.
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));
}
// Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10.
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);
}
/// A 400 × 200 text box at (100, 100) holding `text` at 40 pt, warped by `warp`.
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() }])
}
/// Ink box (x0, y0, x1, y1) of dark pixels, and the top ink row of each column.
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();
// Flat text: the ends and the middle stand on one line.
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}");
// Arched: the line follows the top of the ellipse inscribed in the box, so the middle is
// at the box top and the ends sit clearly lower.
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}");
// At the box top (y = 100), give or take the font: Linux's fallback face reaches 79.
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() };
// Warm the font database first (other tests load fonts concurrently).
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");
}
}
/// A placeable WMF, 2 × 1 inch (2880 × 1440 twips): a solid red brush and a rectangle over the
/// left half, nothing on the right.
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
}
// CREATEBRUSHINDIRECT (solid, COLORREF 0x000000FF = red), SELECTOBJECT 0, RECTANGLE (bottom,
// right, top, left).
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() {
// An embedded OLE object (or an unknown graphic) draws its preview stretched over its frame,
// as PowerPoint does; with no preview it draws nothing.
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");
}
}
/// #270: a filled shape half off the slide's left edge, and one wholly below it.
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);
// The on-slide half is there, nothing else is drawn outside the slide.
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);
// Both halves of the straddling shape, at full strength, and the shape below the slide.
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));
// The pasteboard itself stays clear (the UI's own colour shows through); the slide is white.
assert_eq!(at(-50, 50), [0, 0, 0, 0]);
assert_eq!(at(200, 300), [255, 255, 255, 255]);
// The drawing's reach, in slide space.
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]);
// The box estimate doesn't see the text below the slide; the render does.
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:?}");
// A slide with nothing off it reaches just the slide.
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));
}
/// A placeable WMF (1440 units per inch, bounds `w` × `h`) holding `records` (function, parameter
/// bytes).
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()
}
/// CREATEFONTINDIRECT (height, escapement, face) then SELECTOBJECT of it (handle 0), transparent
/// background, and the alignment `ta`.
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]))]
}
/// EXTTEXTOUT at (x, y) with an optional dx array.
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)
}
/// Bounding box (x0, y0, x1, y1) of the pixels at least half opaque and darker than mid-grey.
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() {
// 2 × 1 inch (144 × 72 pt) drawn 288 px wide: 2 px per point. A 36-point "HH", top-left
// aligned at (72, 18) pt.
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}");
// The cell's top is at 36 px; a capital starts below the cell top and sits on the baseline.
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() {
// TA_CENTER | TA_BASELINE at (72, 36) pt: the ink is centred on 144 px and sits on 72 px.
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}");
// Escapement 90°: the string runs up the picture.
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() {
// "HH" with the second H a full inch (144 px) after the first: two clumps of ink, a gap between.
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() {
// Webdings "n" is a filled circle (installed, or mapped to a black circle where it is not), as
// is Wingdings "l" in a symbol font that is not installed anywhere.
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() {
// OPAQUE background in red behind a space.
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() {
// A 1-bit 8 × 8 checker (palette white, black), a null pen and a one-inch square.
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);
// 4 px per point: a 0.75 pt cell is 3 px.
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:?}");
// Far below one pixel per cell: the average grey.
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:?}");
}
/// A 400 x 200 group at (100, 100) holding a dashed 4 pt rectangle and a 3 pt line with an
/// arrowhead, placed in the group's child coordinates by `to_child` (slide point to child point).
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() {
// Children laid out 1:1 with the slide are the reference.
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}");
// The same drawing in a small legacy child space (scale 100), and one scaled unevenly
// (100 across, 50 down): same outlines, same line widths, dashes and arrowhead.
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() {
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
// logo's shadow, not its frame's. Here the left half of the image is transparent.
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() });
// Below the picture, 10 pt down: shadow under the opaque right half only.
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:?}");
// The transparent half shows the slide, not a shadow behind it.
assert!(img.pixel(200, 200)[0] > 240, "{as_fill}");
}
}
#[test]
fn text_in_a_scaled_group_keeps_its_point_size() {
// A group's child coordinates scale its shapes' boxes, never their type: a 24 pt word in a
// group whose child space is 1/1600 of its size (as old decks write them) or 4 times it is
// 24 pt on the slide, and inside its box (it used to grow until the slide turned black).
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() {
// `a:fillOverlay blend="mult"`: a 50% grey multiplied over a light fill darkens it; "screen"
// lightens a dark one; the overlay stays inside the shape.
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:?}");
}
#[test]
fn dotted_and_wavy_underlines_draw_their_pattern() {
// Underlined spaces only, so the underline is the only ink: a red line under " ".
let row_runs = |u: &str| {
let mut sh = Shape { id: ShapeId(760), xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 300.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text(" ");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(96.0);
props.underline = Some(u.into());
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let red = |x: u32, y: u32| {
let px = img.pixel(x, y);
px[0] > 150 && px[1] < 120
};
// The ink's rows, and the number of separate red stretches along the most inked row.
let rows: Vec<u32> = (0..300).filter(|&y| (0..900).any(|x| red(x, y))).collect();
let best = rows.iter().copied().max_by_key(|&y| (0..900).filter(|&x| red(x, y)).count()).unwrap_or(0);
let stretches = (1..900).filter(|&x| red(x, best) && !red(x - 1, best)).count();
(rows.len(), stretches)
};
let (sng_rows, sng_runs) = row_runs("sng");
assert_eq!(sng_runs, 1, "a single line is one stretch");
let (_, dotted_runs) = row_runs("dotted");
assert!(dotted_runs > 10, "dots: {dotted_runs}");
let (wavy_rows, _) = row_runs("wavy");
assert!(wavy_rows > sng_rows, "a wave spans more rows ({wavy_rows}) than a single line ({sng_rows})");
}
#[test]
fn east_asian_vertical_text_stands_ideographs_upright() {
// "一" is a horizontal stroke. In eaVert text it stays horizontal (upright) while the line runs
// down the shape; in vert it lies on its side like Latin text.
let ink = |dir: deckcraft_model::text::TextDir| {
let mut sh = Shape { id: ShapeId(770), xfrm: Some(Xfrm::new(100.0, 50.0, 200.0, 400.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("一");
t.body.vert = Some(dir);
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(80.0);
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
for y in 0..540 {
for x in 0..960 {
if img.pixel(x, y)[0] < 128 {
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
}
assert!(x1 >= x0 && y1 >= y0, "no ink for {dir:?}");
(x1 - x0, y1 - y0)
};
let (w, h) = ink(deckcraft_model::text::TextDir::EaVertical);
assert!(w > h * 3, "eaVert: upright stroke {w}x{h}");
let (w, h) = ink(deckcraft_model::text::TextDir::Vertical);
assert!(h > w * 3, "vert: stroke on its side {w}x{h}");
}