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) -> 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 = 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 ``: 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 = 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 { 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| { 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) }; // 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) -> 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 = (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::>() }; let close = |got: Vec, 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>) { 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], 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 { fn rec(func: u16, params: &[i16]) -> Vec { 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> = (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)]) -> Vec { 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 { 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)> { 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) { 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 = [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 = (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}"); }