//! Render tests for shape, line, fill and effect cases checked against PowerPoint's own exports //! of synthetic "wild" slides (observed black-box; see `plan/powerpoint/tools/gen_wild.py`). use super::*; use deckcraft_color::Rgba; use deckcraft_model::style::ColorRef; use deckcraft_model::{CustomPath, Geom, Presentation, Shape, ShapeId, 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 } fn render(shapes: Vec) -> Image { render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() }) } fn solid(c: Rgba) -> Option { Some(Fill::solid(ColorRef::rgb(c))) } fn no_line() -> Option { Some(Line { fill: Some(Fill::None), ..Default::default() }) } fn close(px: [u8; 4], c: Rgba, tol: i32) -> bool { (px[0] as i32 - c.r as i32).abs() <= tol && (px[1] as i32 - c.g as i32).abs() <= tol && (px[2] as i32 - c.b as i32).abs() <= tol } const WHITE: Rgba = Rgba::WHITE; const RED: Rgba = Rgba::rgb(255, 0, 0); #[test] fn custom_geometry_sub_paths_cut_holes_whichever_way_they_wind() { // Outer square clockwise, inner square also clockwise: PowerPoint still shows a hole. let d = "M 0 0 L 100 0 L 100 100 L 0 100 Z M 25 25 L 75 25 L 75 75 L 25 75 Z"; let sh = Shape { id: ShapeId(10), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 200.0)), geom: Geom::Custom { paths: vec![CustomPath { w: 100.0, h: 100.0, d: d.into(), ..Default::default() }] }, fill: solid(RED), line: no_line(), ..Default::default() }; let img = render(vec![sh]); assert!(close(img.pixel(120, 120), RED, 2), "outer ring filled"); assert!(close(img.pixel(200, 200), WHITE, 2), "inner square is a hole: {:?}", img.pixel(200, 200)); } fn dotted_line(cap: deckcraft_model::style::LineCap, len: f64) -> Shape { Shape { id: ShapeId(11), xfrm: Some(Xfrm::new(100.0, 100.0, len, 0.0)), geom: Geom::preset("line"), line: Some(Line { fill: solid(RED), width: Some(10.0), dash: Some(deckcraft_model::style::Dash::Preset { name: "sysDot".into() }), cap: Some(cap), ..Default::default() }), ..Default::default() } } #[test] fn round_dots_are_separate_circles_with_the_caps_inside_each_dash() { use deckcraft_model::style::LineCap; // PowerPoint's "Round Dot": one width on, one off, round caps. The caps sit inside the dash, so // the dots stay apart (they used to grow into each other and draw a solid line). let img = render(vec![dotted_line(LineCap::Round, 200.0)]); assert!(close(img.pixel(100, 100), RED, 40), "a dot centred on the start {:?}", img.pixel(100, 100)); assert!(close(img.pixel(120, 100), RED, 40), "the next dot one period on"); assert!(close(img.pixel(110, 100), WHITE, 10), "a gap between dots {:?}", img.pixel(110, 100)); // Flat caps: each dash is a width long, from the start. let img = render(vec![dotted_line(LineCap::Flat, 200.0)]); assert!(close(img.pixel(105, 100), RED, 40) && close(img.pixel(115, 100), WHITE, 10)); // Square caps: dashes end flat; only the line's own start is squared off, half a width back. let img = render(vec![dotted_line(LineCap::Square, 200.0)]); assert!(close(img.pixel(97, 100), RED, 40), "square cap before the start {:?}", img.pixel(97, 100)); assert!(close(img.pixel(105, 100), RED, 40) && close(img.pixel(115, 100), WHITE, 10), "dashes keep their length"); assert!(close(img.pixel(122, 100), RED, 40) && close(img.pixel(118, 100), WHITE, 10), "no cap on the next dash"); // The end: 200 pt is ten whole periods, so the line ends in a gap and gets no cap. assert!(close(img.pixel(302, 100), WHITE, 10), "{:?}", img.pixel(302, 100)); // 205 pt ends half way through a dash: squared off past the end. let img = render(vec![dotted_line(LineCap::Square, 205.0)]); assert!(close(img.pixel(308, 100), RED, 40), "{:?}", img.pixel(308, 100)); } #[test] fn dash_lengths_make_room_for_round_and_square_caps() { assert_eq!(dash_with_caps(vec![4.0, 3.0], kurbo::Cap::Round, 1.0), vec![3.0, 4.0]); assert_eq!(dash_with_caps(vec![1.0, 1.0], kurbo::Cap::Round, 2.0), vec![0.002, 3.0]); assert_eq!(dash_with_caps(vec![1.0, 1.0], kurbo::Cap::Square, 2.0), vec![1.0, 1.0]); assert_eq!(dash_with_caps(vec![4.0, 3.0], kurbo::Cap::Butt, 1.0), vec![4.0, 3.0]); assert_eq!(dash_with_caps(vec![4.0, 3.0, 1.0], kurbo::Cap::Round, 1.0), vec![4.0, 3.0, 1.0]); } fn arrow_line(kind: &str, width: f64, c: Rgba) -> Shape { let end = deckcraft_model::style::LineEnd { kind: kind.into(), w: "med".into(), len: "med".into() }; Shape { id: ShapeId(12), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 0.0)), geom: Geom::preset("line"), line: Some(Line { fill: solid(c), width: Some(width), tail: Some(end), ..Default::default() }), ..Default::default() } } #[test] fn a_wide_line_stops_inside_its_arrowhead() { // 10 pt line, medium triangle (30 pt long, 30 wide) whose tip is the line's end at x = 300. let img = render(vec![arrow_line("triangle", 10.0, RED)]); assert!(close(img.pixel(297, 100), RED, 40), "the tip {:?}", img.pixel(297, 100)); // 2 pt back from the tip the triangle is 2 pt wide: the line's corner must not show there. assert!(close(img.pixel(298, 104), WHITE, 10), "line corner past the head {:?}", img.pixel(298, 104)); } #[test] fn a_see_through_line_and_its_arrowhead_are_one_shape() { let half_red = Rgba::rgba(255, 0, 0, 128); let img = render(vec![arrow_line("triangle", 10.0, half_red)]); let line_only = img.pixel(200, 100); let head_over_line = img.pixel(285, 100); assert!(close(line_only, Rgba::rgb(255, 127, 127), 6), "{line_only:?}"); assert!(close(head_over_line, Rgba::rgb(line_only[0], line_only[1], line_only[2]), 3), "no darker overlap: {head_over_line:?}"); } #[test] fn an_open_arrowheads_point_is_on_the_lines_end() { let img = render(vec![arrow_line("arrow", 8.0, RED)]); let reach = (290..320).rev().find(|x| img.pixel(*x, 100)[1] < 128); assert!(reach.is_some_and(|x| x <= 300), "point reaches {reach:?}"); assert!(reach.is_some_and(|x| x >= 297), "point reaches {reach:?}"); } #[test] fn arrowheads_are_sized_for_at_least_a_two_point_line() { let e = deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "sm".into(), len: "lg".into() }; assert_eq!(head_size(&e, 0.5), (4.0, 10.0)); assert_eq!(head_size(&e, 4.0), (8.0, 20.0)); } #[test] fn trimming_a_path_leaves_its_ends_off_by_arc_length() { let mut p = BezPath::new(); p.move_to((0.0, 0.0)); p.line_to((50.0, 0.0)); p.line_to((50.0, 50.0)); let t = trim_ends(&p, 10.0, 20.0); let els = t.elements(); assert_eq!(els.first(), Some(&PathEl::MoveTo(Point::new(10.0, 0.0)))); match els.last() { Some(PathEl::LineTo(q)) => assert!((q.x - 50.0).abs() < 1e-6 && (q.y - 30.0).abs() < 1e-6, "{q:?}"), other => panic!("{other:?}"), } // Trims longer than the path leave nothing; closed paths are untouched. assert!(trim_ends(&p, 60.0, 50.0).elements().is_empty()); let r = Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1); assert_eq!(trim_ends(&r, 3.0, 3.0).elements().len(), r.elements().len()); } fn radial(focus: [f64; 4]) -> Shape { use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }]; Shape { id: ShapeId(13), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), fill: Some(Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Path { path: "circle".into(), focus }, rotate_with_shape: true, })), line: no_line(), ..Default::default() } } #[test] fn a_radial_gradient_off_centre_ends_on_the_circle_around_the_box() { // Focus a fifth of the way in from the top-left corner. PowerPoint's last colour is reached on // the circle through the box's corners, so the near corner is dark as well as the far one. let img = render(vec![radial([0.2, 0.2, 0.8, 0.8])]); assert!(img.pixel(181, 141)[0] > 240, "white at the focus {:?}", img.pixel(181, 141)); assert!(img.pixel(101, 101)[0] < 60, "near corner dark {:?}", img.pixel(101, 101)); assert!(img.pixel(498, 298)[0] < 60, "far corner dark {:?}", img.pixel(498, 298)); // Straight above the focus the box's edge is well inside the circle: still light. assert!(img.pixel(181, 101)[0] > 150, "{:?}", img.pixel(181, 101)); // A centred one is symmetric, dark only at the corners. let img = render(vec![radial([0.5, 0.5, 0.5, 0.5])]); assert!(img.pixel(300, 200)[0] > 240 && img.pixel(101, 101)[0] < 60); assert!((img.pixel(110, 200)[0] as i32 - img.pixel(490, 200)[0] as i32).abs() <= 2); } fn shadow(sx: f64, sy: f64, kx: f64, align: &str) -> deckcraft_model::style::Shadow { deckcraft_model::style::Shadow { color: ColorRef::rgb(Rgba::BLACK), blur: 0.0, dist: 0.0, dir: 0.0, inner: false, sx, sy, kx, ky: 0.0, align: align.into(), rotate_with_shape: false, } } fn with_effects(fill: Option, line: Option, effects: deckcraft_model::Effects) -> Shape { Shape { id: ShapeId(14), xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 100.0)), fill, line, effects: Some(effects), ..Default::default() } } #[test] fn a_perspective_shadow_lies_along_the_edge_its_alignment_names() { // Half height, skewed 45° so its top leans right, anchored at the bottom middle: the shadow's // bottom edge is the shape's (y = 200) and its top (y = 150) is 50 pt to the right. let fx = deckcraft_model::Effects { outer_shadow: Some(shadow(1.0, 0.5, -45.0, "b")), ..Default::default() }; let img = render(vec![with_effects(solid(RED), no_line(), fx)]); assert!(close(img.pixel(210, 175), Rgba::BLACK, 30), "{:?}", img.pixel(210, 175)); assert!(close(img.pixel(240, 155), Rgba::BLACK, 30), "{:?}", img.pixel(240, 155)); assert!(close(img.pixel(240, 180), WHITE, 10), "{:?}", img.pixel(240, 180)); assert!(close(img.pixel(205, 140), WHITE, 10), "nothing above the half height {:?}", img.pixel(205, 140)); // Anchored bottom-right and half as big again: it grows up and to the left only. let fx = deckcraft_model::Effects { outer_shadow: Some(shadow(1.5, 1.5, 0.0, "br")), ..Default::default() }; let img = render(vec![with_effects(solid(RED), no_line(), fx)]); assert!(close(img.pixel(70, 70), Rgba::BLACK, 30) && close(img.pixel(210, 210), WHITE, 10)); } fn offset_shadow() -> deckcraft_model::Effects { let mut sh = shadow(1.0, 1.0, 0.0, "b"); sh.dist = 20.0; sh.dir = 0.0; deckcraft_model::Effects { outer_shadow: Some(sh), ..Default::default() } } #[test] fn shadows_are_cast_by_what_is_drawn() { // An outline-only square: its shadow (20 pt to the right) is a frame, not a solid square. let outline = Some(Line::solid(ColorRef::rgb(RED), 4.0)); let img = render(vec![with_effects(Some(Fill::None), outline.clone(), offset_shadow())]); assert!(close(img.pixel(220, 150), Rgba::BLACK, 30), "the frame's shadow {:?}", img.pixel(220, 150)); assert!(close(img.pixel(160, 150), WHITE, 10), "no shadow inside the frame {:?}", img.pixel(160, 150)); // A half see-through fill casts a half see-through shadow. let img = render(vec![with_effects(solid(Rgba::rgba(255, 0, 0, 128)), no_line(), offset_shadow())]); let px = img.pixel(210, 150); assert!(close(px, Rgba::rgb(127, 127, 127), 8), "half shadow {px:?}"); // A line casts a shadow too. let mut ln = arrow_line("none", 6.0, RED); ln.effects = Some(offset_shadow()); let img = render(vec![ln]); assert!(close(img.pixel(310, 100), Rgba::BLACK, 30), "line shadow {:?}", img.pixel(310, 100)); } #[allow(clippy::unwrap_used)] fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { let mut out = std::io::Cursor::new(vec![]); image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb(rgb))).write_to(&mut out, image::ImageFormat::Png).unwrap(); out.into_inner() } fn picture_deck(line: Option) -> Presentation { let mut p = Presentation::default(); let media = p.add_media("image1.png", "image/png", png(8, 8, [0, 0, 255])); let sh = Shape { id: ShapeId(15), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, line, ..Default::default() }; std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; p } #[test] fn a_pictures_border_lies_outside_the_picture() { let p = picture_deck(Some(Line::solid(ColorRef::rgb(RED), 8.0))); let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); let blue = Rgba::rgb(0, 0, 255); // The whole picture shows, up to its edge; the 8 pt border is all outside it. assert!(close(img.pixel(101, 150), blue, 30), "picture at its edge {:?}", img.pixel(101, 150)); assert!(close(img.pixel(98, 150), RED, 30) && close(img.pixel(93, 150), RED, 30), "{:?}", img.pixel(93, 150)); assert!(close(img.pixel(90, 150), WHITE, 10), "border no wider than the line {:?}", img.pixel(90, 150)); // A dashed border keeps its dash lengths (a dash is 4 widths: 32 pt), drawn from the corner. let mut line = Line::solid(ColorRef::rgb(RED), 8.0); line.dash = Some(deckcraft_model::style::Dash::Preset { name: "dash".into() }); let img = render_slide(&picture_deck(Some(line)), 0, &RenderOpts { scale: 1.0, ..Default::default() }); assert!(close(img.pixel(120, 96), RED, 30) && close(img.pixel(140, 96), WHITE, 10), "{:?}", img.pixel(140, 96)); } #[test] fn children_filled_with_the_group_fill_show_one_fill_across_the_group() { use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }]; let grad = Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true }); let child = |id: u32, x: f64| Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)), fill: Some(Fill::Group), line: no_line(), ..Default::default() }; let g = Shape { id: ShapeId(16), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), fill: Some(grad), kind: ShapeKind::Group { children: vec![child(17, 0.0), child(18, 110.0)], child: Xfrm::new(0.0, 0.0, 200.0, 100.0) }, ..Default::default() }; let img = render(vec![g]); let (l, r) = (img.pixel(105, 150), img.pixel(295, 150)); assert!(l[0] > 200 && l[2] < 60, "left end of the group's gradient {l:?}"); assert!(r[2] > 200 && r[0] < 60, "right end {r:?}"); assert!(close(img.pixel(200, 150), WHITE, 5), "the gap between the children stays empty"); } #[test] fn open_freeforms_take_arrowheads_even_when_filled() { // An open custom path with a fill and a tail arrow: PowerPoint draws the arrow. let end = deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "med".into(), len: "med".into() }; let sh = Shape { id: ShapeId(19), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), geom: Geom::Custom { paths: vec![CustomPath { w: 100.0, h: 100.0, d: "M 0 100 L 50 0 L 100 100".into(), ..Default::default() }] }, fill: solid(Rgba::rgb(0, 200, 0)), line: Some(Line { fill: solid(RED), width: Some(4.0), tail: Some(end), ..Default::default() }), ..Default::default() }; let img = render(vec![sh]); // The line ends at (300, 200) heading down-right at 45°; the 12 pt head's base corner reaches // (295.7, 187.3), 3.5 pt off the line's axis: outside the 4 pt line and outside the fill. assert!(close(img.pixel(294, 189), RED, 60), "arrowhead drawn {:?}", img.pixel(294, 189)); } #[test] fn a_corner_radial_centres_on_the_corner_through_its_tile() { // fillToRect on the top-left corner, tileRect reaching a whole box up and left: the circle is // centred on the corner and reaches the far corner (as PowerPoint's "from corner" presets). let mut sh = radial([0.0, 0.0, 1.0, 1.0]); if let Some(Fill::Gradient(g)) = &mut sh.fill { g.tile = [-1.0, -1.0, 0.0, 0.0]; } let img = render(vec![sh]); assert!(img.pixel(102, 102)[0] > 240, "white at the corner {:?}", img.pixel(102, 102)); let (top_right, bottom_left) = (img.pixel(498, 101)[0], img.pixel(101, 298)[0]); assert!(top_right < bottom_left, "farther is darker {top_right} {bottom_left}"); assert!(bottom_left > 120, "the near edge stays light {bottom_left}"); assert!(img.pixel(497, 297)[0] < 40, "dark at the far corner {:?}", img.pixel(497, 297)); } #[test] fn a_linear_gradient_spans_its_tile_rect() { use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; // Tile rect the left half of the box: the ramp ends half way and stays black after. let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }]; let g = Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, tile: [0.0, 0.0, 0.5, 0.0] }; let sh = Shape { id: ShapeId(20), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)), fill: Some(Fill::Gradient(g)), line: no_line(), ..Default::default() }; let img = render(vec![sh]); assert!(img.pixel(102, 150)[0] > 240); assert!(img.pixel(305, 150)[0] < 15 && img.pixel(495, 150)[0] < 15, "{:?}", img.pixel(305, 150)); } #[test] fn a_3d_pictures_border_stays_centred_on_its_edge() { let mut p = picture_deck(Some(Line::solid(ColorRef::rgb(RED), 8.0))); let s = std::sync::Arc::make_mut(&mut p.slides[0]); if let Some(sh) = s.shapes.first_mut() { sh.effects = Some(deckcraft_model::Effects { bevel: Some("circle".into()), raw3d: Some("".into()), ..Default::default() }); } let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); assert!(close(img.pixel(102, 150), RED, 30), "covers the picture's edge {:?}", img.pixel(102, 150)); assert!(close(img.pixel(94, 150), WHITE, 10), "reaches half the width out {:?}", img.pixel(94, 150)); } #[allow(clippy::unwrap_used)] fn half_red_half_blue() -> Vec { let img = image::RgbImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) }); let mut out = std::io::Cursor::new(vec![]); image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap(); out.into_inner() } fn tiled(sx: f64, flip: &str, align: &str) -> Image { use deckcraft_model::style::{PictureFill, PictureMode}; let mut p = Presentation::default(); let media = p.add_media("t.png", "image/png", half_red_half_blue()); let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx, sy: 10.0, flip: flip.into(), align: align.into() }; let sh = Shape { id: ShapeId(21), xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)), fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })), line: no_line(), ..Default::default() }; std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }) } #[test] fn tiled_pictures_flip_align_and_skip_non_positive_scales() { let blue = Rgba::rgb(0, 0, 255); // 8×4 px at 96 dpi scaled ten times: 60×30 pt tiles, red then blue, from the top-left. let img = tiled(10.0, "none", "tl"); assert!(close(img.pixel(115, 115), RED, 30) && close(img.pixel(145, 115), blue, 30) && close(img.pixel(175, 115), RED, 30)); // Flipped across x: every other tile mirrored, so the second starts blue. let img = tiled(10.0, "x", "tl"); assert!(close(img.pixel(145, 115), blue, 30) && close(img.pixel(175, 115), blue, 30) && close(img.pixel(205, 115), RED, 30)); // Aligned right: a tile ends at the box's right edge (340), so 310-340 is blue. let img = tiled(10.0, "none", "r"); assert!(close(img.pixel(335, 130), blue, 30) && close(img.pixel(305, 130), RED, 30), "{:?}", img.pixel(305, 130)); // A negative scale draws nothing, as in PowerPoint. let img = tiled(-10.0, "none", "tl"); assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5)); } #[test] fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() { let b = Rect::new(0.0, 0.0, 100.0, 50.0); assert_eq!(paint::tile_origin(b, "tl", (20.0, 10.0), (0.0, 0.0)), Point::new(0.0, 0.0)); assert_eq!(paint::tile_origin(b, "ctr", (20.0, 10.0), (0.0, 0.0)), Point::new(40.0, 20.0)); assert_eq!(paint::tile_origin(b, "br", (20.0, 10.0), (5.0, -5.0)), Point::new(85.0, 35.0)); assert_eq!(paint::tile_origin(b, "nonsense", (20.0, 10.0), (f64::NAN, 1.0)), Point::new(0.0, 1.0)); } /// `png` with a pHYs chunk saying `dpi` inserted after its header. fn with_dpi(png: &[u8], dpi: f64) -> Vec { fn crc32(data: &[u8]) -> u32 { let mut c = 0xFFFF_FFFFu32; for b in data { c ^= *b as u32; for _ in 0..8 { c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; } } !c } let ppm = (dpi / 0.0254).round() as u32; let mut chunk = b"pHYs".to_vec(); chunk.extend_from_slice(&ppm.to_be_bytes()); chunk.extend_from_slice(&ppm.to_be_bytes()); chunk.push(1); let mut out = png[..33].to_vec(); // signature (8) + IHDR chunk (25) out.extend_from_slice(&9u32.to_be_bytes()); out.extend_from_slice(&chunk); out.extend_from_slice(&crc32(&chunk).to_be_bytes()); out.extend_from_slice(&png[33..]); out } #[test] fn picture_resolution_sizes_tiles() { let png = half_red_half_blue(); assert_eq!(images::dpi(&png), None); let p48 = with_dpi(&png, 48.0); let d = images::dpi(&p48).unwrap_or_default(); assert!((d.0 - 48.0).abs() < 0.01 && (d.1 - 48.0).abs() < 0.01, "{d:?}"); // 8 × 4 px at 48 dpi is as big as 16 × 8 at 96. assert_eq!(images::natural_size(&p48), Some((16.0, 8.0))); assert_eq!(images::natural_size(&png), Some((8.0, 4.0))); // JFIF density in dots per inch. let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 16]; jpeg.extend_from_slice(b"JFIF\0\x01\x01\x01\x00\x48\x00\x48"); assert_eq!(images::dpi(&jpeg), Some((72.0, 72.0))); } #[test] fn gradient_lines_and_their_heads_take_the_gradient() { use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; let blue = Rgba::rgb(0, 0, 255); let grad = |angle: f64| { Fill::Gradient(Gradient { stops: vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(blue) }], shape: GradientShape::Linear { angle, scaled: false }, rotate_with_shape: true, tile: [0.0; 4], }) }; // Left to right along a 20 pt line: red at its start, blue at its end, and the arrowhead at // the end blue too. let mut sh = arrow_line("triangle", 20.0, RED); if let Some(l) = &mut sh.line { l.fill = Some(grad(0.0)); } let img = render(vec![sh]); let (start, mid, head) = (img.pixel(102, 100), img.pixel(200, 100), img.pixel(296, 100)); assert!(start[0] > 200 && start[2] < 60, "{start:?}"); assert!(mid[0] > 60 && mid[2] > 60, "{mid:?}"); assert!(head[2] > 200 && head[0] < 60, "head {head:?}"); // Top to bottom across a horizontal line: the gradient runs over the line's width. let mut sh = arrow_line("none", 20.0, RED); if let Some(l) = &mut sh.line { l.fill = Some(grad(90.0)); } let img = render(vec![sh]); let (top, bottom) = (img.pixel(200, 91), img.pixel(200, 109)); assert!(top[0] > 200 && top[2] < 60 && bottom[2] > 200 && bottom[0] < 60, "{top:?} {bottom:?}"); } #[test] fn a_groups_shadow_is_cast_by_its_children() { let child = |id: u32, x: f64| Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, 0.0, 80.0, 100.0)), fill: solid(RED), line: no_line(), ..Default::default() }; let g = Shape { id: ShapeId(22), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Group { children: vec![child(23, 0.0), child(24, 120.0)], child: Xfrm::new(0.0, 0.0, 200.0, 100.0) }, effects: Some(offset_shadow()), ..Default::default() }; let img = render(vec![g]); // 20 pt to the right of each child: the shadow; in the gap only the left child's shadow shows. assert!(close(img.pixel(190, 150), Rgba::BLACK, 30), "{:?}", img.pixel(190, 150)); assert!(close(img.pixel(210, 150), WHITE, 10), "{:?}", img.pixel(210, 150)); assert!(close(img.pixel(310, 150), Rgba::BLACK, 30), "{:?}", img.pixel(310, 150)); assert!(close(img.pixel(150, 150), RED, 10), "children drawn over it"); } #[test] fn patterns_between_whole_pixel_cells_blend_neighbouring_cells() { use deckcraft_model::style::PatternFill; // narVert (alternate cells) at 0.75 output pixels per cell: PowerPoint's export reads // 255, 128, 0 repeating across; drawing the nearest cell gave only black and white. Bilinear // sampling gives about 213, 128, 42 (closer on average over all presets, not exact). let g = |v: u8| ColorRef::rgb(Rgba::rgb(v, v, v)); let sh = Shape { id: ShapeId(25), xfrm: Some(Xfrm::new(0.0, 0.0, 60.0, 60.0)), fill: Some(Fill::Pattern(PatternFill { preset: "narVert".into(), fg: g(0), bg: g(255) })), line: no_line(), ..Default::default() }; let img = render(vec![sh]); let row: Vec = (12..24).map(|x| img.pixel(x, 20)[0]).collect(); assert!(row.iter().any(|v| (100..=156).contains(v)), "a blended column: {row:?}"); assert!(row.iter().any(|v| *v < 60) && row.iter().any(|v| *v > 200), "{row:?}"); // The pattern repeats every 6 px (8 cells). assert!(row.iter().zip(row.iter().skip(6)).all(|(a, b)| (*a as i32 - *b as i32).abs() <= 2), "{row:?}"); } #[test] fn a_bevelled_shape_is_lit_from_the_upper_left() { let base = Rgba::rgb(91, 155, 213); let bevelled = |raw: &str| Shape { id: ShapeId(26), xfrm: Some(Xfrm::new(100.0, 100.0, 130.0, 130.0)), fill: solid(base), line: no_line(), effects: Some(deckcraft_model::Effects { bevel: Some("circle".into()), raw3d: Some(raw.into()), ..Default::default() }), ..Default::default() }; let raw = r#""#; let img = render(vec![bevelled(raw)]); // PowerPoint: face (99, 166, 226); top of the band saturates, its bottom and right darken. let face = img.pixel(165, 165); assert!(close(face, Rgba::rgb(99, 166, 226), 8), "face {face:?}"); let (top, bottom, right) = (img.pixel(165, 104)[2], img.pixel(165, 226)[2], img.pixel(226, 165)[2]); assert!(top > face[2] && bottom < face[2] && right < face[2], "top {top} bottom {bottom} right {right} face {}", face[2]); // Without a 3-D scene PowerPoint draws it flat. let img = render(vec![bevelled(r#""#)]); assert!(close(img.pixel(165, 104), base, 2) && close(img.pixel(165, 165), base, 2)); } #[test] fn table_borders_take_their_dashes_and_diagonals_are_drawn() { let mut t = deckcraft_model::Table::new(1, 1, 200.0, 100.0); t.style = "none".into(); let mut dashed = Line::solid(ColorRef::rgb(RED), 4.0); dashed.dash = Some(deckcraft_model::style::Dash::Preset { name: "dash".into() }); if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) { c.borders[2] = Some(dashed); c.diag_down = Some(Line::solid(ColorRef::rgb(Rgba::rgb(0, 0, 255)), 4.0)); } let sh = Shape { id: ShapeId(27), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() }; let img = render(vec![sh]); // Dashes of 4 widths (16 pt) on, 3 (12 pt) off along the top edge, from its start. assert!(close(img.pixel(108, 100), RED, 40) && close(img.pixel(122, 100), WHITE, 30) && close(img.pixel(135, 100), RED, 40)); // The diagonal from top-left to bottom-right passes through the middle. assert!(close(img.pixel(200, 150), Rgba::rgb(0, 0, 255), 40), "{:?}", img.pixel(200, 150)); } #[test] fn a_large_pictures_shadow_is_whole() { // A 400 px picture shown at 240 pt: its shadow was recoloured in the picture's pixel space and // covered only part of it. let mut counts = vec![]; for side in [8u32, 400] { let mut p = Presentation::default(); let media = p.add_media("image1.png", "image/png", png(side, side * 3 / 4, [0, 0, 255])); let mut fx = offset_shadow(); if let Some(o) = &mut fx.outer_shadow { o.blur = 8.0; } let sh = Shape { id: ShapeId(15), xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(360.0, 300.0, 240.0, 160.0) }), kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, effects: Some(fx), ..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() }); counts.push( (0..img.height) .flat_map(|y| (0..img.width).map(move |x| (x, y))) .filter(|(x, y)| { let q = img.pixel(*x, *y); q[0] < 200 && q[2] < 200 }) .count(), ); } assert!(counts[1] > 3000 && (counts[0] as i64 - counts[1] as i64).abs() < 300, "{counts:?}"); }