deckcraft/crates/render/src/tests_wild.rs

713 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! 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<Shape>) -> 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<Shape>) -> Image {
render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
fn solid(c: Rgba) -> Option<Fill> {
Some(Fill::solid(ColorRef::rgb(c)))
}
fn no_line() -> Option<Line> {
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<Fill>, line: Option<Line>, 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<u8> {
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<Line>) -> 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("<a:scene3d><a:camera prst=\"orthographicFront\"/></a:scene3d><a:sp3d><a:bevelT/></a:sp3d>".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<u8> {
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<u8> {
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<u8> = (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#"<a:scene3d><a:camera prst="orthographicFront"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d><a:bevelT w="152400" h="76200"/></a:sp3d>"#;
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#"<a:sp3d><a:bevelT w="152400" h="76200"/></a:sp3d>"#)]);
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:?}");
}
#[test]
fn a_gradient_not_rotating_with_its_shape_keeps_to_the_slide() {
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
let blue = Rgba::rgb(0, 0, 255);
for rotate_with_shape in [true, false] {
let g = Gradient {
stops: vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(blue) }],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape,
tile: [0.0; 4],
};
// 200 × 100 turned a quarter: it covers 150..250 across and 50..250 down.
let sh = Shape {
id: ShapeId(28),
xfrm: Some(Xfrm { rot: 90.0, ..Xfrm::new(100.0, 100.0, 200.0, 100.0) }),
fill: Some(Fill::Gradient(g)),
line: no_line(),
..Default::default()
};
let img = render(vec![sh]);
let (left, top) = (img.pixel(152, 150), img.pixel(200, 52));
if rotate_with_shape {
assert!(top[0] > 200 && top[2] < 60, "turned with the shape: red at the top {top:?}");
} else {
assert!(left[0] > 200 && left[2] < 60, "upright: red at the left {left:?}");
assert!(img.pixel(248, 150)[2] > 200, "blue at the right");
}
}
}
#[test]
fn stars_and_right_arrows_have_sharp_corners_by_default() {
let shape = |name: &str, join: Option<deckcraft_model::style::LineJoin>| Shape {
id: ShapeId(29),
xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 100.0)),
geom: Geom::preset(name),
fill: solid(WHITE),
line: Some(Line { join, ..Line::solid(ColorRef::rgb(RED), 10.0) }),
..Default::default()
};
// A star5's top point mitred reaches about 16 pt above the box; rounded only 5.
let img = render(vec![shape("star5", None)]);
assert!(close(img.pixel(150, 90), RED, 40), "mitred point {:?}", img.pixel(150, 90));
let img = render(vec![shape("star5", Some(deckcraft_model::style::LineJoin::Round))]);
assert!(close(img.pixel(150, 90), WHITE, 10), "an explicit round join stays round");
// A rectangle keeps PowerPoint's round default: its corner is cut round.
let img = render(vec![shape("rect", None)]);
assert!(close(img.pixel(95, 95), WHITE, 30), "{:?}", img.pixel(95, 95));
assert!(deckcraft_geom::preset::mitred_by_default("rightArrow") && !deckcraft_geom::preset::mitred_by_default("leftArrow"));
}
#[test]
fn an_upright_picture_fill_stays_upright_in_a_turned_shape() {
use deckcraft_model::style::PictureFill;
let blue = Rgba::rgb(0, 0, 255);
for upright in [false, true] {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue());
// Half red (left) half blue picture in a 200 × 200 square turned a quarter (and so
// covering the same square).
let sh = Shape {
id: ShapeId(30),
xfrm: Some(Xfrm { rot: 90.0, ..Xfrm::new(100.0, 100.0, 200.0, 200.0) }),
fill: Some(Fill::Picture(PictureFill { media, upright, ..Default::default() })),
line: no_line(),
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let (left, top) = (img.pixel(110, 200), img.pixel(200, 110));
if upright {
assert!(close(left, RED, 30) && close(img.pixel(290, 200), blue, 30), "upright: red left {left:?}");
} else {
assert!(close(top, RED, 30), "turned with the shape: red at the top {top:?}");
}
}
}