mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 19:17:06 -06:00
A text box with a glow in its shape properties but no fill and no line got a blurred ring round the whole box; PowerPoint shows only the text's own glow. The glow of a frame with a line, and of a filled shape, is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2614 lines
125 KiB
Rust
2614 lines
125 KiB
Rust
use super::*;
|
||
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
|
||
use deckcraft_model::style::ColorRef;
|
||
use deckcraft_model::text::TextBody;
|
||
use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
|
||
|
||
fn deck_with(shapes: Vec<Shape>) -> Presentation {
|
||
let mut p = Presentation::default();
|
||
let s = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||
s.shapes = shapes;
|
||
p
|
||
}
|
||
|
||
#[test]
|
||
fn blank_slide_renders_white_background() {
|
||
let p = Presentation::default();
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() });
|
||
assert_eq!((img.width, img.height), (240, 135));
|
||
assert_eq!(img.pixel(5, 5), [255, 255, 255, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn shape_fill_uses_theme_accent() {
|
||
let sh = Shape {
|
||
id: ShapeId(500),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||
..Default::default()
|
||
};
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let accent = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||
let px = img.pixel(200, 150);
|
||
assert!((px[0] as i32 - accent.r as i32).abs() <= 2 && (px[2] as i32 - accent.b as i32).abs() <= 2, "{px:?} vs {accent:?}");
|
||
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn render_shapes_draws_the_selection_on_a_transparent_canvas() {
|
||
let sh = Shape {
|
||
id: ShapeId(880),
|
||
xfrm: Some(Xfrm::new(100.0, 80.0, 60.0, 40.0)),
|
||
fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))),
|
||
line: Some(deckcraft_model::Line::none()),
|
||
..Default::default()
|
||
};
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_shapes(&p, &p.slides[0], &[ShapeId(880)], 1.0).expect("picture");
|
||
let mid = img.pixel(img.width / 2, img.height / 2);
|
||
assert!(mid[0] > 200 && mid[1] < 40 && mid[3] > 200, "{mid:?}");
|
||
assert_eq!(img.pixel(0, 0)[3], 0, "{:?}", img.pixel(0, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn ink_pressure_tapers_the_stroke_and_a_flat_half_stays_full_width() {
|
||
let opts = RenderOpts { scale: 1.0, ..Default::default() };
|
||
let tapered = render_slide(&ink_line(1.0, 0.12), 0, &opts);
|
||
let flat = render_slide(&ink_line(0.5, 0.5), 0, &opts);
|
||
let full = render_slide(&ink_line(1.0, 1.0), 0, &opts);
|
||
let heavy = dark_in(&tapered, 50, 140, 70, 130);
|
||
let light = dark_in(&tapered, 280, 360, 70, 130);
|
||
assert!(heavy > light * 3, "a firm point covers more of the slide than a light one ({heavy} vs {light})");
|
||
let (a, b) = (dark_in(&flat, 40, 370, 70, 130), dark_in(&full, 40, 370, 70, 130));
|
||
assert!(a > 100 && (a as i32 - b as i32).unsigned_abs() * 10 < a as u32, "the old constant 0.5 still draws at the pen width ({a} vs {b})");
|
||
}
|
||
|
||
fn ink_line(left: f32, right: f32) -> Presentation {
|
||
let points = (0..=40)
|
||
.map(|i| {
|
||
let pressure = if i < 20 { left } else { right };
|
||
(40.0 + f64::from(i) * 8.0, 100.0, pressure)
|
||
})
|
||
.collect();
|
||
let sh = Shape {
|
||
id: ShapeId(7),
|
||
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
|
||
kind: ShapeKind::Ink {
|
||
strokes: vec![deckcraft_model::InkStroke { points, color: deckcraft_color::Rgba::rgb(0, 0, 0), width: 24.0, highlighter: false }],
|
||
},
|
||
..Default::default()
|
||
};
|
||
deck_with(vec![sh])
|
||
}
|
||
|
||
fn dark_in(img: &Image, x0: u32, x1: u32, y0: u32, y1: u32) -> usize {
|
||
let mut n = 0;
|
||
for y in y0..y1 {
|
||
for x in x0..x1 {
|
||
if img.pixel(x, y)[0] < 200 {
|
||
n += 1;
|
||
}
|
||
}
|
||
}
|
||
n
|
||
}
|
||
|
||
#[test]
|
||
fn text_draws_ink() {
|
||
let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() };
|
||
let mut t = TextBody::from_text("HELLO WORLD");
|
||
t.paragraphs[0].runs[0].props.size = Some(120.0);
|
||
sh.text = Some(t);
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||
let dark = img.pixels.as_chunks::<4>().0.iter().filter(|px| px[0] < 128).count();
|
||
assert!(dark > 500, "dark pixels {dark}");
|
||
}
|
||
|
||
#[test]
|
||
fn every_preset_renders_without_panic() {
|
||
let shapes: Vec<Shape> = deckcraft_geom::preset::CATALOG
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, pr)| Shape {
|
||
id: ShapeId(1000 + i as u32),
|
||
xfrm: Some(Xfrm::new((i % 16) as f64 * 60.0, (i / 16) as f64 * 55.0, 50.0, 45.0)),
|
||
geom: Geom::preset(pr.name),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
|
||
..Default::default()
|
||
})
|
||
.collect();
|
||
let p = deck_with(shapes);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, ..Default::default() });
|
||
assert_eq!(img.width, 960);
|
||
}
|
||
|
||
#[test]
|
||
fn effects_tables_charts_render() {
|
||
let mut sh = Shape {
|
||
id: ShapeId(600),
|
||
xfrm: Some(Xfrm::new(50.0, 50.0, 200.0, 100.0)),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||
..Default::default()
|
||
};
|
||
sh.effects = Some(deckcraft_model::Effects {
|
||
outer_shadow: Some(deckcraft_model::style::Shadow {
|
||
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
|
||
blur: 8.0,
|
||
dist: 6.0,
|
||
dir: 45.0,
|
||
inner: false,
|
||
sx: 1.0,
|
||
sy: 1.0,
|
||
kx: 0.0,
|
||
ky: 0.0,
|
||
align: String::new(),
|
||
rotate_with_shape: false,
|
||
}),
|
||
glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 200, 0)), radius: 6.0 }),
|
||
soft_edge: Some(4.0),
|
||
..Default::default()
|
||
});
|
||
let mut t = Table::new(3, 3, 300.0, 30.0);
|
||
t.cell_mut(0, 0).unwrap().text = TextBody::from_text("Head");
|
||
let tbl = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(300.0, 50.0, 300.0, 90.0)), kind: ShapeKind::Table(t), ..Default::default() };
|
||
let mut charts = vec![];
|
||
for (i, k) in [
|
||
ChartType::Column,
|
||
ChartType::Bar,
|
||
ChartType::Line,
|
||
ChartType::Pie,
|
||
ChartType::Doughnut,
|
||
ChartType::Area,
|
||
ChartType::Scatter,
|
||
ChartType::StackedColumn,
|
||
]
|
||
.iter()
|
||
.enumerate()
|
||
{
|
||
charts.push(Shape {
|
||
id: ShapeId(700 + i as u32),
|
||
xfrm: Some(Xfrm::new(i as f64 * 110.0, 300.0, 100.0, 100.0)),
|
||
kind: ShapeKind::Chart(Box::new(Chart::sample(*k))),
|
||
..Default::default()
|
||
});
|
||
}
|
||
let mut all = vec![sh, tbl];
|
||
all.extend(charts);
|
||
let p = deck_with(all);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
// Shadow darkens below-right of the shape (grey, past the glow), blurred by a third of its
|
||
// 8 pt blur radius.
|
||
let px = img.pixel(255, 155);
|
||
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
|
||
// Columns in the hundreds of thousands, a green line in the thousands on the secondary axis:
|
||
// the line must sit up in the plot, not flat along the bottom of the primary scale.
|
||
let mut c = Chart::sample(ChartType::Combo);
|
||
c.title = None;
|
||
c.legend = None;
|
||
c.series.truncate(2);
|
||
c.categories = vec!["A".into(), "B".into(), "C".into()];
|
||
c.series[0].kind = Some(ChartType::Column);
|
||
c.series[0].values = vec![Some(200_000.0), Some(300_000.0), Some(250_000.0)];
|
||
c.series[1].kind = Some(ChartType::Line);
|
||
c.series[1].values = vec![Some(7_000.0), Some(7_000.0), Some(7_000.0)];
|
||
c.series[1].secondary_axis = true;
|
||
c.series[1].line = Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 80)), 3.0));
|
||
let sh = Shape { id: ShapeId(620), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let green = |x: u32, y0: u32, y1: u32| {
|
||
(y0..y1).any(|y| {
|
||
let p = img.pixel(x, y);
|
||
p[1] > 120 && p[0] < 80 && p[2] < 140
|
||
})
|
||
};
|
||
// The line is in the upper half (7 000 of an axis to 8 000), and columns fill the plot's bottom.
|
||
assert!(green(480, 20, 270), "line not in the upper half");
|
||
assert!(!green(480, 400, 500), "line along the bottom");
|
||
let col = img.pixel(480, 480);
|
||
assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}");
|
||
}
|
||
|
||
fn trend_chart(title: &str, kind: ChartType, ys: &[f64], trend: Trendline, y_max: f64) -> Chart {
|
||
let mut c = Chart::sample(kind);
|
||
c.title = Some(title.into());
|
||
c.legend = None;
|
||
c.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
|
||
c.series.truncate(1);
|
||
c.series[0].name = "Data".into();
|
||
c.series[0].values = ys.iter().copied().map(Some).collect();
|
||
c.y_min = Some(0.0);
|
||
c.y_max = Some(y_max);
|
||
c.series[0].trendlines = vec![trend];
|
||
c
|
||
}
|
||
|
||
fn trend_line(kind: TrendlineType) -> Trendline {
|
||
Trendline { kind, disp_eq: true, disp_r_sqr: true, ..Default::default() }
|
||
}
|
||
|
||
/// Six charts, one trendline type each. `draw` drops the trendlines. `forecast` extends the linear fit.
|
||
fn trend_gallery(draw: bool, forecast: bool) -> Image {
|
||
let mut linear = trend_line(TrendlineType::Linear);
|
||
if forecast {
|
||
linear.forward = Some(1.0);
|
||
linear.backward = Some(0.5);
|
||
}
|
||
let mut poly = trend_line(TrendlineType::Poly);
|
||
poly.order = Some(2);
|
||
let mut ma = trend_line(TrendlineType::MovingAvg);
|
||
ma.period = Some(2);
|
||
let mut charts = vec![
|
||
trend_chart("Linear", ChartType::Column, &[3.0, 5.0, 7.0, 9.0], linear, 12.0),
|
||
trend_chart("Exponential", ChartType::LineMarkers, &[1.0, 2.0, 4.0, 8.0], trend_line(TrendlineType::Exp), 10.0),
|
||
trend_chart(
|
||
"Logarithmic",
|
||
ChartType::LineMarkers,
|
||
&[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()],
|
||
trend_line(TrendlineType::Log),
|
||
2.0,
|
||
),
|
||
trend_chart("Polynomial", ChartType::LineMarkers, &[1.0, 4.0, 9.0, 16.0], poly, 18.0),
|
||
trend_chart("Power", ChartType::LineMarkers, &[2.0, 2.0 * 2.0_f64.sqrt(), 2.0 * 3.0_f64.sqrt(), 4.0], trend_line(TrendlineType::Power), 6.0),
|
||
trend_chart("Moving average", ChartType::LineMarkers, &[2.0, 8.0, 4.0, 10.0], ma, 12.0),
|
||
];
|
||
if !draw {
|
||
for c in &mut charts {
|
||
c.series[0].trendlines.clear();
|
||
}
|
||
}
|
||
let shapes = charts
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(i, c)| Shape {
|
||
id: ShapeId(800 + i as u32),
|
||
xfrm: Some(Xfrm::new(12.0 + (i % 3) as f64 * 316.0, 12.0 + (i / 3) as f64 * 264.0, 304.0, 252.0)),
|
||
kind: ShapeKind::Chart(Box::new(c)),
|
||
..Default::default()
|
||
})
|
||
.collect();
|
||
render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||
}
|
||
|
||
#[test]
|
||
fn trendlines_draw_through_the_points_with_a_label() {
|
||
let on = trend_gallery(true, false);
|
||
let off = trend_gallery(false, false);
|
||
assert_eq!((on.width, on.height), (960, 540));
|
||
let dark = on
|
||
.pixels
|
||
.as_chunks::<4>()
|
||
.0
|
||
.iter()
|
||
.zip(off.pixels.as_chunks::<4>().0)
|
||
.filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20)
|
||
.count();
|
||
assert!(dark > 40, "dashed trendline ink {dark}");
|
||
// A scatter fit uses the real x values and still paints the dash.
|
||
let mut scatter = trend_chart("Scatter", ChartType::Scatter, &[2.0, 4.0, 6.0, 8.0], trend_line(TrendlineType::Linear), 10.0);
|
||
scatter.series[0].x_values = (1..=4).map(|x| Some(x as f64)).collect();
|
||
let sh = Shape {
|
||
id: ShapeId(810),
|
||
xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)),
|
||
kind: ShapeKind::Chart(Box::new(scatter.clone())),
|
||
..Default::default()
|
||
};
|
||
let with = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
scatter.series[0].trendlines.clear();
|
||
let sh =
|
||
Shape { id: ShapeId(810), xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), kind: ShapeKind::Chart(Box::new(scatter)), ..Default::default() };
|
||
let without = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let scatter_dark = with
|
||
.pixels
|
||
.as_chunks::<4>()
|
||
.0
|
||
.iter()
|
||
.zip(without.pixels.as_chunks::<4>().0)
|
||
.filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20)
|
||
.count();
|
||
assert!(scatter_dark > 20, "scatter trendline ink {scatter_dark}");
|
||
if std::env::var_os("DECKCRAFT_TREND_SHOT").is_some() {
|
||
let png = trend_gallery(true, true).to_png();
|
||
std::fs::write("/tmp/deckcraft-trendlines.png", &png).expect("write trendline screenshot");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn chart_axis_titles_are_drawn() {
|
||
let draw = |titles: (Option<String>, Option<String>)| {
|
||
let mut c = Chart::sample(ChartType::Column);
|
||
c.title = None;
|
||
c.legend = None;
|
||
c.axis_titles = titles;
|
||
let sh = Shape { id: ShapeId(624), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||
};
|
||
let ink = |img: &Image, r: (u32, u32, u32, u32)| {
|
||
(r.0..r.2).flat_map(|x| (r.1..r.3).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[0] < 160).count()
|
||
};
|
||
let bare = draw((None, None));
|
||
let titled = draw((Some("Month".into()), Some("Revenue".into())));
|
||
// A turned "Revenue" down the left edge, "Month" along the bottom.
|
||
let left = (38, 150, 62, 390);
|
||
let bottom = (380, 490, 580, 520);
|
||
assert!(ink(&titled, left) > ink(&bare, left) + 30, "value axis title");
|
||
assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title");
|
||
}
|
||
|
||
#[test]
|
||
fn column_points_take_their_own_fill_and_smooth_lines_curve() {
|
||
// One column series where only the middle point is given a red c:dPt fill.
|
||
let mut c = Chart::sample(ChartType::Column);
|
||
c.title = None;
|
||
c.legend = None;
|
||
c.series.truncate(1);
|
||
c.categories = vec!["A".into(), "B".into(), "C".into()];
|
||
c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)];
|
||
c.series[0].point_fills = vec![(1, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))];
|
||
let sh = Shape { id: ShapeId(621), 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 red = |x: u32| (300..500).any(|y| img.pixel(x, y)[..3] == [255, 0, 0]);
|
||
assert!(red(480), "the middle column takes its point's fill");
|
||
assert!(!red(200) && !red(760), "the others keep the series colour");
|
||
|
||
// Two series, gap 150 %, no overlap: each bar is a band / 3.5 wide (PowerPoint's and
|
||
// LibreOffice's geometry), not band / 2.5 split in two.
|
||
let mut c = Chart::sample(ChartType::Column);
|
||
c.title = None;
|
||
c.legend = None;
|
||
c.series.truncate(2);
|
||
c.categories = vec!["A".into()];
|
||
c.series[0].values = vec![Some(10.0)];
|
||
c.series[1].values = vec![Some(10.0)];
|
||
c.series[0].fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||
(c.gap_width, c.overlap) = (1.5, 0.0);
|
||
let sh = Shape { id: ShapeId(622), 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 y = (0..540).rev().find(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).unwrap() - 5;
|
||
let reds = (0..960).filter(|x| img.pixel(*x, y)[..3] == [255, 0, 0]).count() as f64;
|
||
// The plot is ~850 px wide with one category: a bar is ~240 px (it was ~170 when the band
|
||
// was split as 1 + gap and then shared between the series).
|
||
assert!((200.0..280.0).contains(&reds), "bar {reds} px");
|
||
|
||
// A smoothed line through a peak overshoots no point but leaves the straight chord.
|
||
let pts = [kurbo::Point::new(0.0, 0.0), kurbo::Point::new(10.0, 10.0), kurbo::Point::new(20.0, 0.0)];
|
||
let p = crate::chart::smooth_path(&pts);
|
||
assert_eq!(p.elements().len(), 3, "a cubic per segment");
|
||
let mid = kurbo::ParamCurve::eval(&p.segments().next().unwrap(), 0.5);
|
||
assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_bars_list_the_first_category_at_the_bottom_unless_reversed() {
|
||
let draw = |reversed: bool| {
|
||
let mut c = Chart::sample(ChartType::Bar);
|
||
c.title = None;
|
||
c.legend = None;
|
||
c.series.truncate(1);
|
||
c.categories = vec!["A".into(), "B".into(), "C".into()];
|
||
c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)];
|
||
c.series[0].point_fills = vec![(0, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))];
|
||
c.cat_reversed = reversed;
|
||
let sh = Shape { id: ShapeId(623), 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 rows: Vec<u32> = (0..540).filter(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).collect();
|
||
(rows[0] + rows[rows.len() - 1]) / 2
|
||
};
|
||
assert!(draw(false) > 360, "first category at the bottom: {}", draw(false));
|
||
assert!(draw(true) < 180, "reversed: first category at the top: {}", draw(true));
|
||
}
|
||
|
||
#[test]
|
||
fn glow_of_an_unfilled_shape_follows_its_line() {
|
||
// A frame (no fill, thin line) with a strong glow: the glow lines its edge, the inside stays
|
||
// the slide's white, as PowerPoint draws it.
|
||
let glow = |fill: Option<Fill>| {
|
||
let mut sh = Shape {
|
||
id: ShapeId(610),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 300.0)),
|
||
fill,
|
||
line: Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 240)), 0.25)),
|
||
..Default::default()
|
||
};
|
||
sh.effects = Some(deckcraft_model::Effects {
|
||
glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 255)), radius: 8.0 }),
|
||
..Default::default()
|
||
});
|
||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||
};
|
||
let img = glow(Some(Fill::None));
|
||
let inside = img.pixel(300, 250);
|
||
assert!(inside[0] > 245 && inside[1] > 245, "{inside:?}");
|
||
let edge = img.pixel(300, 97);
|
||
assert!(edge[0] < 230, "{edge:?}");
|
||
// A filled shape's glow is still the whole silhouette's (the fill covers its inside).
|
||
let img = glow(Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))));
|
||
assert!(img.pixel(300, 94)[0] < 240);
|
||
}
|
||
|
||
#[test]
|
||
fn glow_of_a_shape_with_no_fill_and_no_line_is_not_drawn() {
|
||
// A text box (no fill, no line) with a glow in its shape properties: PowerPoint shows no halo
|
||
// round the box (its text carries its own glow), so the box's edge and surroundings stay white.
|
||
let mut sh = Shape { id: ShapeId(611), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 300.0)), fill: Some(Fill::None), ..Default::default() };
|
||
sh.effects = Some(deckcraft_model::Effects {
|
||
glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 0)), radius: 15.0 }),
|
||
..Default::default()
|
||
});
|
||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
for (x, y) in [(300, 250), (300, 100), (300, 95), (96, 250)] {
|
||
let px = img.pixel(x, y);
|
||
assert!(px[2] > 245, "({x}, {y}): {px:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn placeholder_prompts_only_in_edit_view() {
|
||
let p = Presentation::default();
|
||
let edit = render_slide(&p, 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
|
||
let show = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||
let ink = |i: &Image| i.pixels.as_chunks::<4>().0.iter().filter(|px| px[0] < 200).count();
|
||
assert!(ink(&edit) > 100);
|
||
assert_eq!(ink(&show), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn png_encodes() {
|
||
let p = Presentation::default();
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 0.1, ..Default::default() });
|
||
let png = img.to_png();
|
||
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
|
||
assert_eq!(image_dpi(&png), None, "an untagged screenshot stays without a resolution");
|
||
assert!(!img.to_jpeg(80).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn a_retina_export_records_144_dpi_and_still_decodes() {
|
||
assert_eq!(dpi_for_scale(2.0), 144.0);
|
||
assert_eq!(dpi_for_scale(1.0), 72.0);
|
||
assert_eq!(dpi_for_scale(f64::NAN), 72.0);
|
||
let img = Image { width: 4, height: 2, pixels: [255, 0, 0, 255].repeat(8) };
|
||
let png = img.to_png_at(144.0);
|
||
assert_eq!(image_dpi(&png), Some((144.0, 144.0)));
|
||
let decoded = image::load_from_memory(&png).unwrap();
|
||
assert_eq!((decoded.width(), decoded.height()), (4, 2));
|
||
let again = images::stamp_png_dpi(&png, 72.0);
|
||
assert_eq!(again.len(), png.len(), "a second pHYs chunk is not written");
|
||
assert_eq!(image_dpi(&again), Some((144.0, 144.0)));
|
||
let jpeg = img.to_jpeg_at(90, Some(144.0));
|
||
assert_eq!(image_dpi(&jpeg), Some((144.0, 144.0)));
|
||
assert_eq!(image_dpi(&img.to_jpeg(90)), None);
|
||
}
|
||
|
||
#[test]
|
||
fn crop_dest_math() {
|
||
let r = crop_dest(Rect::new(0.0, 0.0, 100.0, 100.0), [0.5, 0.0, 0.0, 0.0]);
|
||
assert!((r.x0 + 100.0).abs() < 1e-9 && (r.width() - 200.0).abs() < 1e-9);
|
||
let r = crop_dest(Rect::new(0.0, 0.0, 100.0, 100.0), [f64::NAN, 2.0, -50.0, 0.0]);
|
||
assert!(r.x0.is_finite() && r.y1.is_finite());
|
||
}
|
||
|
||
#[test]
|
||
fn custom_path_parse() {
|
||
let p = parse_path("M 0 0 L 10 0 C 1 2 3 4 5 6 Q 1 1 2 2 Z garbage L x y");
|
||
assert!(p.elements().len() >= 5);
|
||
assert!(parse_path("").elements().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn table_rows_of_zero_height_grow_to_fit_text() {
|
||
// pandoc and python-pptx write `<a:tr h="0">`: the stored row height is only a minimum.
|
||
let mut t = Table::new(3, 2, 300.0, 0.0);
|
||
for (r, row) in [["Col A", "Col B"], ["1", "alpha"], ["2", "beta"]].iter().enumerate() {
|
||
for (c, text) in row.iter().enumerate() {
|
||
t.cell_mut(r, c).unwrap().text = TextBody::from_text(text);
|
||
}
|
||
}
|
||
let tbl = Shape { id: ShapeId(610), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 0.0)), kind: ShapeKind::Table(t.clone()), ..Default::default() };
|
||
let p = deck_with(vec![tbl]);
|
||
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||
let hs = crate::table::row_heights(&ctx, &t, &deckcraft_text::NoFields);
|
||
assert_eq!(hs.len(), 3);
|
||
assert!(hs.iter().all(|h| *h > 10.0), "{hs:?}");
|
||
let tops: Vec<f64> = crate::table::cell_rects(&t, &hs).iter().filter(|(_, c, _)| *c == 0).map(|(_, _, r)| r.y0).collect();
|
||
assert!(tops.len() == 3 && tops.windows(2).all(|w| w[1] > w[0] + 10.0), "{tops:?}");
|
||
// A taller stored height is kept.
|
||
let mut tall = t;
|
||
tall.rows[1].height = 200.0;
|
||
assert_eq!(crate::table::row_heights(&ctx, &tall, &deckcraft_text::NoFields)[1], 200.0);
|
||
// The PDF text layer gets every cell's text, rows one below the other.
|
||
let placed = place_slide(&p, 0);
|
||
let top = |s: &str| {
|
||
let pt = placed.texts.iter().find(|pt| pt.body.text() == s).unwrap_or_else(|| panic!("no placed text {s:?}"));
|
||
(pt.transform * kurbo::Point::new(0.0, pt.layout.lines[0].top)).y
|
||
};
|
||
assert!(top("Col A") < top("1") && top("1") < top("2"));
|
||
assert!((top("alpha") - top("1")).abs() < 1.0 && (top("beta") - top("2")).abs() < 1.0);
|
||
assert!(top("Col B") >= 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_ends_match_the_slides_and_mixes_backdrops() {
|
||
let sh = Shape {
|
||
id: ShapeId(500),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||
..Default::default()
|
||
};
|
||
let mut p = deck_with(vec![sh.clone()]);
|
||
let mut dark = (*p.slides[0]).clone();
|
||
dark.background =
|
||
Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))) });
|
||
p.slides.push(std::sync::Arc::new(dark));
|
||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||
let shapes = vec![(sh, false)];
|
||
// At the ends the frame is the slide itself; the shape stays opaque throughout.
|
||
assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels);
|
||
let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &[], &o);
|
||
let px = mid.pixel(5, 5);
|
||
assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}");
|
||
assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75));
|
||
// Hostile input: bad indices and NaN never panic.
|
||
assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &[], &o).width, 0);
|
||
let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o);
|
||
}
|
||
|
||
fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape {
|
||
let mut t = TextBody::from_text(text);
|
||
for p in &mut t.paragraphs {
|
||
for r in &mut p.runs {
|
||
r.props.size = Some(40.0);
|
||
}
|
||
}
|
||
Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, y, 600.0, 120.0)), text_box: true, text: Some(t), ..Default::default() }
|
||
}
|
||
|
||
#[test]
|
||
fn morph_text_tokens_are_words_or_characters() {
|
||
let p = Presentation::default();
|
||
let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||
let s = text_shape(1, "Hello big world\nHello", 0.0, 0.0);
|
||
let l = deckcraft_text::layout(
|
||
&rctx,
|
||
&s,
|
||
s.text.as_ref().unwrap(),
|
||
&Opts { rect: Rect::new(0.0, 0.0, 600.0, 120.0), fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||
);
|
||
assert_eq!(morph_text::token_keys(&l, false), ["Hello", "big", "world", "Hello"]);
|
||
assert_eq!(morph_text::token_keys(&l, true).concat(), "Hellobigworld".to_string() + "Hello");
|
||
assert_eq!(morph_text::token_keys(&l, true).len(), 18);
|
||
}
|
||
|
||
#[test]
|
||
fn morph_text_ends_match_the_slides() {
|
||
let from = Xfrm::new(100.0, 60.0, 600.0, 120.0);
|
||
let to = Xfrm::new(200.0, 300.0, 600.0, 120.0);
|
||
let mut p = deck_with(vec![text_shape(1, "Hello wide world", from.x, from.y)]);
|
||
let mut b = (*p.slides[0]).clone();
|
||
b.shapes = vec![text_shape(1, "world says Hello", to.x, to.y)];
|
||
p.slides.push(std::sync::Arc::new(b));
|
||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||
let frame = |t: f64| {
|
||
let x = Xfrm::new(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, 600.0, 120.0);
|
||
let mut sb = text_shape(10, "world says Hello", 0.0, 0.0);
|
||
let mut sa = text_shape(11, "Hello wide world", 0.0, 0.0);
|
||
sb.xfrm = Some(x);
|
||
sa.xfrm = Some(x);
|
||
let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t };
|
||
render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &[], &o)
|
||
};
|
||
// Warm up: faces load lazily (a background scan runs), so the first render may still fall back.
|
||
let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5));
|
||
let close = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 8).count();
|
||
let d0 = close(&frame(0.0), &render_slide(&p, 0, &o));
|
||
assert!(d0 < 40, "start differs {d0}");
|
||
let d1 = close(&frame(1.0), &render_slide(&p, 1, &o));
|
||
assert!(d1 < 40, "end differs {d1}");
|
||
// Halfway the shared words are on their way: ink between the two texts' rows.
|
||
let mid = frame(0.5);
|
||
let ink = (95..140).flat_map(|y| (0..480).map(move |x| (x, y))).filter(|&(x, y)| mid.pixel(x, y)[0] < 128).count();
|
||
assert!(ink > 50, "ink {ink}");
|
||
}
|
||
|
||
/// A text box holding one run of `kind` (a field type, or plain text for `None`) saved as `saved`.
|
||
fn field_shape(kind: Option<&str>, saved: &str) -> Shape {
|
||
let mut sh = text_shape(1, saved, 40.0, 40.0);
|
||
if let (Some(k), Some(run)) = (kind, sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut())) {
|
||
run.kind = deckcraft_model::text::RunKind::Field { field: k.into() };
|
||
}
|
||
sh
|
||
}
|
||
|
||
#[test]
|
||
fn date_fields_show_the_date_in_their_format() {
|
||
use deckcraft_text::datetime::DateTime;
|
||
let fri = DateTime::new(2026, 10, 9, 20, 5, 7);
|
||
let at = |p: &Presentation, now: DateTime| render_slide(p, 0, &RenderOpts { scale: 0.5, now: Some(now), ..Default::default() });
|
||
let mut p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
|
||
let _ = at(&p, fri); // faces load lazily
|
||
// The field shows the date (it changes with the clock), not its saved text.
|
||
let a = at(&p, fri);
|
||
assert!(diff_px(&a, &at(&p, DateTime::new(2027, 3, 4, 0, 0, 0))) > 0);
|
||
let shown = deck_with(vec![field_shape(None, "10/9/2026")]);
|
||
assert_eq!(diff_px(&a, &at(&shown, fri)), 0, "datetime1 is M/D/YYYY");
|
||
// Another slide's fixed date (a deck-wide Header & Footer text) doesn't replace it.
|
||
p.header_footer.date_text = "Fixed on another slide".into();
|
||
assert_eq!(diff_px(&a, &at(&p, fri)), 0);
|
||
// Every type has its format; the run's language orders numeric dates.
|
||
let f = Renderer::fields(&p, 0, &RenderOpts { now: Some(fri), ..Default::default() });
|
||
assert_eq!(f.field_in("datetime4", Some("en-US")).as_deref(), Some("October 9, 2026"));
|
||
assert_eq!(f.field_in("datetimeFigureOut", Some("en-GB")).as_deref(), Some("09/10/2026"));
|
||
assert_eq!(f.field("datetime13").as_deref(), Some("8:05:07 PM"));
|
||
assert_eq!(f.field("slidenum").as_deref(), Some("1"));
|
||
// A date the app can't compute (no clock, unknown type, other-language names) keeps the saved text.
|
||
assert_eq!(SlideFields { now: None, ..f }.field("datetime1"), None);
|
||
let unknown = deck_with(vec![field_shape(Some("datetime99"), "Saved text")]);
|
||
let plain = deck_with(vec![field_shape(None, "Saved text")]);
|
||
assert_eq!(diff_px(&at(&unknown, fri), &at(&plain, fri)), 0);
|
||
// An explicit override still wins.
|
||
let o = RenderOpts { date_text: Some("Today".into()), now: Some(fri), ..Default::default() };
|
||
assert_eq!(Renderer::fields(&p, 0, &o).field("datetime2").as_deref(), Some("Today"));
|
||
}
|
||
|
||
/// Channel values that differ noticeably between two images of the same size.
|
||
fn diff_px(a: &Image, b: &Image) -> usize {
|
||
a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count()
|
||
}
|
||
|
||
/// Two 8×8 frames, red then blue, 100 ms each.
|
||
fn two_frame_gif() -> Vec<u8> {
|
||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||
let frame = |c: [u8; 4]| Frame::from_parts(RgbaImage::from_pixel(8, 8, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
|
||
let mut out = Vec::new();
|
||
image::codecs::gif::GifEncoder::new(&mut out).encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])]).expect("gif");
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn animated_gif_shows_the_frame_for_its_time() {
|
||
let mut p = Presentation::default();
|
||
let media = p.add_media("a.gif", "image/gif", two_frame_gif()).expect("media");
|
||
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
|
||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||
let at = |t: Option<f64>| {
|
||
let times: Vec<(ShapeId, f64)> = t.map(|t| vec![(ShapeId(600), t)]).unwrap_or_default();
|
||
render_slide(&p, 0, &RenderOpts { scale: 0.1, gif_times: ×, ..Default::default() }).pixel(48, 27)
|
||
};
|
||
let red = |px: [u8; 4]| px[0] > 200 && px[2] < 60;
|
||
let blue = |px: [u8; 4]| px[2] > 200 && px[0] < 60;
|
||
assert!(red(at(None)), "no time: the first frame, as exports draw it");
|
||
assert!(red(at(Some(0.05))));
|
||
assert!(blue(at(Some(0.15))));
|
||
assert!(red(at(Some(0.25))), "it loops");
|
||
let bytes = p.media(media).map(|m| m.data.clone()).expect("media");
|
||
let adj = Default::default();
|
||
assert_eq!(gif_frame(&bytes, &adj, 0.15).map(|f| f.0), Some(1));
|
||
assert_eq!(gif_frame(&bytes, &adj, f64::NAN).map(|f| f.0), Some(0));
|
||
assert!(gif_frame(&std::sync::Arc::new(b"\x89PNG".to_vec()), &adj, 0.0).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn morph_path_ends_match_the_slides() {
|
||
let from = Xfrm::new(100.0, 100.0, 200.0, 200.0);
|
||
let to = Xfrm::new(500.0, 150.0, 300.0, 150.0);
|
||
let form = |id: u32, g: &str, x: Xfrm| Shape {
|
||
id: ShapeId(id),
|
||
xfrm: Some(x),
|
||
geom: Geom::preset(g),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||
..Default::default()
|
||
};
|
||
let mut p = deck_with(vec![form(1, "ellipse", from)]);
|
||
let mut b = (*p.slides[0]).clone();
|
||
b.shapes = vec![form(1, "star5", to)];
|
||
p.slides.push(std::sync::Arc::new(b));
|
||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||
let frame = |t: f64| {
|
||
let lerp = |a: f64, b: f64| a + (b - a) * t;
|
||
let x = Xfrm::new(lerp(from.x, to.x), lerp(from.y, to.y), lerp(from.w, to.w), lerp(from.h, to.h));
|
||
let pm = PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t };
|
||
render_blend(&p, 0, 1, t, &[(form(10, "star5", x), false)], &[], &[pm], &o)
|
||
};
|
||
// 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 morph_path_aligns_once_per_transition() {
|
||
let (from, to) = (Xfrm::new(0.0, 0.0, 200.0, 200.0), Xfrm::new(0.0, 0.0, 300.0, 150.0));
|
||
let star = Shape { geom: Geom::preset("star5"), ..Default::default() };
|
||
let pm = |t: f64| PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t };
|
||
let els = |(g, f): (deckcraft_geom::preset::Geometry, Vec<_>)| (g.paths.iter().map(|p| p.path.elements().to_vec()).collect::<Vec<_>>(), f);
|
||
let mut cache = morph_path::Cache::default();
|
||
for t in [0.0, 0.3, 0.7, 1.0] {
|
||
let got = els(cache.geometry(&pm(t), &star, 250.0, 175.0));
|
||
// The same as aligning afresh.
|
||
assert_eq!(got, els(morph_path::Cache::default().geometry(&pm(t), &star, 250.0, 175.0)));
|
||
cache.sweep();
|
||
}
|
||
assert_eq!(cache.len(), 1);
|
||
// Another target outline is another morph.
|
||
let _ = cache.geometry(&pm(0.5), &Shape { geom: Geom::preset("rect"), ..Default::default() }, 250.0, 175.0);
|
||
assert_eq!(cache.len(), 2);
|
||
// A frame without morphs ends the transition: the cache empties.
|
||
cache.sweep();
|
||
cache.sweep();
|
||
assert_eq!(cache.len(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn text_transform_unmirrors_flips() {
|
||
// Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text.
|
||
let tr = Rect::new(10.0, 0.0, 40.0, 50.0);
|
||
let at = |x: Xfrm, p: Point| text_transform(x.affine(), tr, 0.0) * p;
|
||
let near = |a: Point, b: Point| (a - b).hypot() < 1e-9;
|
||
let base = Xfrm::new(0.0, 0.0, 100.0, 50.0);
|
||
// flipH: the rect moves to 60..90 and the text still runs left to right.
|
||
let h = Xfrm { flip_h: true, ..base };
|
||
assert!(near(at(h, Point::new(10.0, 0.0)), Point::new(60.0, 0.0)));
|
||
assert!(near(at(h, Point::new(40.0, 50.0)), Point::new(90.0, 50.0)));
|
||
// flipV: the text is turned 180° in its (vertically mirrored) rect.
|
||
let v = Xfrm { flip_v: true, ..base };
|
||
assert!(near(at(v, Point::new(10.0, 0.0)), Point::new(40.0, 50.0)));
|
||
assert!(near(at(v, Point::new(40.0, 50.0)), Point::new(10.0, 0.0)));
|
||
// Both flips: the same as a 180° turn.
|
||
let hv = Xfrm { flip_h: true, flip_v: true, ..base };
|
||
let turned = Xfrm { rot: 180.0, ..base };
|
||
for p in [Point::new(10.0, 0.0), Point::new(40.0, 50.0), Point::new(25.0, 10.0)] {
|
||
assert!(near(at(hv, p), at(turned, p)));
|
||
}
|
||
// Unflipped and hostile input: plain transform, nothing NaN from the helper itself.
|
||
assert_eq!(text_transform(base.affine(), tr, 0.0), base.affine());
|
||
let _ = text_transform(h.affine(), Rect::new(f64::NAN, 0.0, f64::INFINITY, 1.0), f64::NAN);
|
||
}
|
||
|
||
/// Slide with text box `x` holding left-aligned, asymmetric text.
|
||
fn flipped_text(x: Xfrm, group: Option<Xfrm>) -> Image {
|
||
let mut s = text_shape(1, "Fly 42 RJ", 0.0, 0.0);
|
||
s.xfrm = Some(x);
|
||
let s = match group {
|
||
Some(g) => Shape {
|
||
id: ShapeId(2),
|
||
xfrm: Some(g),
|
||
kind: ShapeKind::Group { children: vec![s], child: Xfrm::new(0.0, 0.0, 600.0, 120.0) },
|
||
..Default::default()
|
||
},
|
||
None => s,
|
||
};
|
||
render_slide(&deck_with(vec![s]), 0, &RenderOpts { scale: 0.5, ..Default::default() })
|
||
}
|
||
|
||
fn differing(a: &Image, b: &Image) -> usize {
|
||
a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count()
|
||
}
|
||
|
||
#[test]
|
||
fn flipped_shapes_draw_readable_text() {
|
||
let base = Xfrm::new(180.0, 200.0, 600.0, 120.0);
|
||
let plain = flipped_text(base, None);
|
||
// Warm up: faces load lazily, so the first render may still fall back.
|
||
let plain = if differing(&plain, &flipped_text(base, None)) > 0 { flipped_text(base, None) } else { plain };
|
||
let turned = flipped_text(Xfrm { rot: 180.0, ..base }, None);
|
||
assert!(differing(&plain, &turned) > 200, "the comparisons below must mean something");
|
||
// flipH: text as unflipped (the text box is symmetric). flipV and both: turned 180°.
|
||
let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..base }, None));
|
||
assert!(d < 40, "flipH mirrored the text: {d}");
|
||
let d = differing(&turned, &flipped_text(Xfrm { flip_v: true, ..base }, None));
|
||
assert!(d < 40, "flipV mirrored the text: {d}");
|
||
let d = differing(&turned, &flipped_text(Xfrm { flip_h: true, flip_v: true, ..base }, None));
|
||
assert!(d < 40, "flipH+flipV: {d}");
|
||
// Rotation then flipH: still the rotated text, not its mirror image.
|
||
let rot = Xfrm { rot: 30.0, ..base };
|
||
let d = differing(&flipped_text(rot, None), &flipped_text(Xfrm { flip_h: true, ..rot }, None));
|
||
assert!(d < 40, "rotated flipH: {d}");
|
||
// Groups: a flipped group's text isn't mirrored; a flipped child in a flipped group neither.
|
||
let child = Xfrm::new(0.0, 0.0, 600.0, 120.0);
|
||
let d = differing(&plain, &flipped_text(child, Some(Xfrm { flip_h: true, ..base })));
|
||
assert!(d < 40, "group flipH: {d}");
|
||
let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..child }, Some(Xfrm { flip_h: true, ..base })));
|
||
assert!(d < 40, "flipH child in flipH group: {d}");
|
||
let d = differing(&turned, &flipped_text(child, Some(Xfrm { flip_v: true, ..base })));
|
||
assert!(d < 40, "group flipV: {d}");
|
||
}
|
||
|
||
/// A text box with one run of `props` (40 pt).
|
||
fn run_deck(props: deckcraft_model::RunProps) -> Presentation {
|
||
let mut sh = text_shape(1, "Linked text", 40.0, 40.0);
|
||
if let Some(run) = sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut()) {
|
||
run.props = deckcraft_model::RunProps { size: Some(40.0), ..props };
|
||
}
|
||
deck_with(vec![sh])
|
||
}
|
||
|
||
#[test]
|
||
fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
|
||
use deckcraft_model::RunProps;
|
||
use deckcraft_model::text::{Action, Hyperlink};
|
||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||
let px = |p: &Presentation| render_slide(p, 0, &o);
|
||
let diff = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count();
|
||
let link = Some(Hyperlink {
|
||
action: Action::Url { url: "https://example.org".into() },
|
||
tooltip: String::new(),
|
||
highlight_click: false,
|
||
sound: None,
|
||
stop_sound: false,
|
||
zoom_return: None,
|
||
});
|
||
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()).expect("media");
|
||
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 a_transparent_background_leaves_the_slide_background_out() {
|
||
// #784: PNG export with a transparent background draws only what is on the slide. The
|
||
// background (a solid colour here) and its white underlay are left out; the shapes, and a
|
||
// shape filled with the background (`useBgFill`), still draw.
|
||
use deckcraft_model::style::Fill;
|
||
let blue = ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 200));
|
||
let red = Shape {
|
||
id: ShapeId(784),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(220, 0, 0)))),
|
||
..Default::default()
|
||
};
|
||
let bg_filled = Shape { id: ShapeId(785), xfrm: Some(Xfrm::new(500.0, 100.0, 200.0, 100.0)), fill: Some(Fill::Background), ..Default::default() };
|
||
let mut p = deck_with(vec![red, bg_filled]);
|
||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(blue) });
|
||
let opaque = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
assert_eq!(opaque.pixel(20, 20), [0, 0, 200, 255], "without the option the background draws");
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, transparent_background: true, ..Default::default() });
|
||
assert_eq!(img.pixel(20, 20), [0, 0, 0, 0], "no background, no white underlay");
|
||
assert_eq!(img.pixel(200, 150), [220, 0, 0, 255], "the slide's shapes still draw");
|
||
assert_eq!(img.pixel(600, 150), [0, 0, 200, 255], "a background-filled shape still shows the background");
|
||
// The default theme background (a style reference) is left out too.
|
||
let img = render_slide(&deck_with(vec![]), 0, &RenderOpts { scale: 0.25, transparent_background: true, ..Default::default() });
|
||
assert!(img.pixels.as_chunks::<4>().0.iter().all(|px| px[3] == 0), "a blank slide is fully transparent");
|
||
}
|
||
|
||
#[test]
|
||
fn colour_glyphs_paint_their_layers() {
|
||
// A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue
|
||
// square in its middle, U+E001 a square in the text colour.
|
||
deckcraft_fonts::FontDb::global().add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec());
|
||
let mut sh = Shape { id: ShapeId(720), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), text_box: true, ..Default::default() };
|
||
let mut t = TextBody::from_text("\u{E000} \u{E001}");
|
||
let props = &mut t.paragraphs[0].runs[0].props;
|
||
props.size = Some(72.0);
|
||
props.font = Some("DeckCraft Colr Squares".into());
|
||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 160, 0))));
|
||
sh.text = Some(t);
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let count = |f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
|
||
let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30);
|
||
let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230);
|
||
let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30);
|
||
// 72 pt squares: the blue one is 40 % of the side (16 % of the area).
|
||
assert!(red > 2500 && blue > 500, "red {red} blue {blue}");
|
||
let share = blue as f64 / (red + blue) as f64;
|
||
assert!((share - 0.16).abs() < 0.03, "blue share {share}");
|
||
// The text-colour glyph is as big as the other square.
|
||
assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue);
|
||
}
|
||
|
||
#[test]
|
||
fn a_text_gradient_starts_over_on_every_line() {
|
||
// PowerPoint fills each line of gradient text with the whole gradient, spanning that line
|
||
// (black at the top of every line to white at its bottom), not the text box.
|
||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
|
||
let mut sh = Shape { id: ShapeId(730), xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)), text_box: true, ..Default::default() };
|
||
let mut t = TextBody::from_text("HHHH\nHHHH");
|
||
for p in &mut t.paragraphs {
|
||
let props = &mut p.runs[0].props;
|
||
props.size = Some(80.0);
|
||
props.fill = Some(Fill::Gradient(Gradient {
|
||
tile: [0.0; 4],
|
||
stops: vec![stop(0.0, 0), stop(1.0, 255)],
|
||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||
rotate_with_shape: true,
|
||
}));
|
||
}
|
||
sh.text = Some(t);
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
// Ink rows (any pixel not white), grouped into the two lines.
|
||
let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250);
|
||
let mut bands: Vec<(u32, u32)> = vec![];
|
||
for y in 0..500 {
|
||
if ink(y) {
|
||
match bands.last_mut() {
|
||
Some(b) if b.1 + 1 == y => b.1 = y,
|
||
_ => bands.push((y, y)),
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(bands.len(), 2, "{bands:?}");
|
||
// The darkest ink in the first rows of each line.
|
||
let top = |b: (u32, u32)| (b.0..b.0 + 6).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
|
||
let (a, b) = (top(bands[0]), top(bands[1]));
|
||
assert!(a < 140 && (a as i32 - b as i32).abs() < 25, "line tops {a} and {b}: the second line starts dark again");
|
||
// ...and each line ends light.
|
||
let bottom = |b: (u32, u32)| (b.1 - 5..=b.1).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
|
||
for band in bands {
|
||
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
|
||
}
|
||
}
|
||
|
||
/// A text box of big black "IIII" with `set` applied to its run.
|
||
fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation {
|
||
let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() };
|
||
let mut t = TextBody::from_text("IIII");
|
||
let props = &mut t.paragraphs[0].runs[0].props;
|
||
props.size = Some(100.0);
|
||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
|
||
set(props);
|
||
sh.text = Some(t);
|
||
deck_with(vec![sh])
|
||
}
|
||
|
||
#[test]
|
||
fn text_glow_and_shadow_are_drawn() {
|
||
let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
|
||
let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
|
||
let plain = effect_text(|_| {});
|
||
assert!(!needs_filters(&plain, &plain.slides[0]));
|
||
let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
assert_eq!(count(&img, &green), 0);
|
||
// Glow: a green halo hugging the letters, needing the blur filter.
|
||
let glow =
|
||
effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 }));
|
||
assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs");
|
||
let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
|
||
let halo = count(&img, &green);
|
||
assert!(halo > 300, "green halo pixels {halo}");
|
||
// Shadow: grey ink below and right of the letters, where the plain text has none.
|
||
let shadow = effect_text(|p| {
|
||
p.shadow = Some(deckcraft_model::style::Shadow {
|
||
color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }),
|
||
blur: 2.0,
|
||
dist: 12.0,
|
||
dir: 45.0,
|
||
inner: false,
|
||
sx: 1.0,
|
||
sy: 1.0,
|
||
kx: 0.0,
|
||
ky: 0.0,
|
||
align: String::new(),
|
||
rotate_with_shape: false,
|
||
})
|
||
});
|
||
let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
|
||
let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2];
|
||
assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears");
|
||
}
|
||
|
||
#[test]
|
||
fn pattern_fills_draw_powerpoints_tiles() {
|
||
// dkVert at one output pixel per pattern cell (0.75 pt): columns 11001100… across, as
|
||
// PowerPoint's export draws it; pct50 is a checkerboard.
|
||
use deckcraft_model::style::{Fill, PatternFill};
|
||
let rgb = |v| ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v));
|
||
let shape = |id, x: f64, preset: &str| Shape {
|
||
id: ShapeId(id),
|
||
xfrm: Some(Xfrm::new(x, 0.0, 60.0, 60.0)),
|
||
fill: Some(Fill::Pattern(PatternFill { preset: preset.into(), fg: rgb(0), bg: rgb(255) })),
|
||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||
..Default::default()
|
||
};
|
||
let p = deck_with(vec![shape(750, 0.0, "dkVert"), shape(751, 120.0, "pct50")]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 4.0 / 3.0, ..Default::default() });
|
||
let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 128;
|
||
let row: Vec<bool> = (8..16).map(|x| dark(x, 20)).collect();
|
||
assert_eq!(row, [true, true, false, false, true, true, false, false], "dkVert columns");
|
||
assert!((8..16).all(|y| dark(8, y) == dark(8, 20)), "dkVert is vertical");
|
||
// pct50 starts at slide x = 120 pt = 160 px.
|
||
for (x, y) in [(168, 8), (169, 9), (170, 12)] {
|
||
assert_ne!(dark(x, y), dark(x + 1, y), "pct50 alternates across at {x},{y}");
|
||
assert_eq!(dark(x, y), dark(x + 1, y + 1), "pct50 repeats diagonally at {x},{y}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn compound_lines_draw_their_bands() {
|
||
// A 24 pt outline on a rectangle, as PowerPoint's export draws each kind (top edge, outside
|
||
// first): double 8/8/8, thin-thick 4.8/4.8/14.4, triple 4/4/8/4/4.
|
||
use deckcraft_model::style::{Compound, Fill, Line};
|
||
let runs = |c: Compound| {
|
||
let sh = Shape {
|
||
id: ShapeId(760),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
|
||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
|
||
line: Some(Line {
|
||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))),
|
||
width: Some(24.0),
|
||
compound: Some(c),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 2.0, ..Default::default() });
|
||
// Down the middle of the top edge (y 100 pt), from 80 pt to 120 pt: lengths of dark and
|
||
// light runs in points.
|
||
let mut out: Vec<(bool, f64)> = vec![];
|
||
for y in 160..240 {
|
||
let dark = img.pixel(500, y)[0] < 128;
|
||
match out.last_mut() {
|
||
Some((d, n)) if *d == dark => *n += 0.5,
|
||
_ => out.push((dark, 0.5)),
|
||
}
|
||
}
|
||
out.into_iter().filter(|(d, _)| *d).map(|(_, n)| n).collect::<Vec<f64>>()
|
||
};
|
||
let close = |got: Vec<f64>, want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0);
|
||
assert!(close(runs(Compound::Single), &[24.0]));
|
||
let d = runs(Compound::Double);
|
||
assert!(close(d.clone(), &[8.0, 8.0]), "{d:?}");
|
||
let t = runs(Compound::ThinThick);
|
||
assert!(close(t.clone(), &[4.8, 14.4]), "{t:?}");
|
||
let k = runs(Compound::ThickThin);
|
||
assert!(close(k.clone(), &[14.4, 4.8]), "{k:?}");
|
||
let tri = runs(Compound::Triple);
|
||
assert!(close(tri.clone(), &[4.0, 8.0, 4.0]), "{tri:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn rect_path_gradients_draw_x_shaped_contours() {
|
||
// path="rect" from the centre, black to grey to white (linear): PowerPoint's colour position is
|
||
// the larger axis share, so 20 pt right of centre in a 280x200 box is 1/7 and 20 pt down 1/5.
|
||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
|
||
let sh = Shape {
|
||
id: ShapeId(770),
|
||
xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)),
|
||
fill: Some(Fill::Gradient(Gradient {
|
||
tile: [0.0; 4],
|
||
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
|
||
shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
|
||
rotate_with_shape: true,
|
||
})),
|
||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||
..Default::default()
|
||
};
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let v = |x: u32, y: u32| img.pixel(x, y)[0] as f64;
|
||
assert!((v(160, 100) - 255.0 / 7.0).abs() < 6.0, "{}", v(160, 100));
|
||
assert!((v(140, 120) - 51.0).abs() < 6.0, "{}", v(140, 120));
|
||
// Along the diagonal to a corner the colour follows the vertical share.
|
||
assert!((v(168, 120) - 51.0).abs() < 6.0, "{}", v(168, 120));
|
||
}
|
||
|
||
#[test]
|
||
fn soft_edges_fade_to_the_outline() {
|
||
// An ellipse with a 12 pt soft edge: transparent at its outline, half opaque about 10 pt
|
||
// inside, opaque further in, as in PowerPoint's export (the fade follows the curve, not the
|
||
// bounding box).
|
||
let sh = Shape {
|
||
id: ShapeId(780),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
|
||
geom: Geom::Preset { name: "ellipse".into(), adj: vec![] },
|
||
fill: Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))),
|
||
line: Some(deckcraft_model::style::Line { fill: Some(deckcraft_model::style::Fill::None), ..Default::default() }),
|
||
effects: Some(deckcraft_model::Effects { soft_edge: Some(12.0), ..Default::default() }),
|
||
..Default::default()
|
||
};
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let alpha = |x: u32, y: u32| 1.0 - img.pixel(x, y)[0] as f64 / 255.0;
|
||
// Top of the ellipse is at y = 100, x = 250.
|
||
assert!(alpha(250, 101) < 0.08, "edge {}", alpha(250, 101));
|
||
let mid = alpha(250, 110);
|
||
assert!((0.3..0.7).contains(&mid), "0.85 r inside: {mid}");
|
||
assert!(alpha(250, 130) > 0.97, "inside {}", alpha(250, 130));
|
||
// Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn.
|
||
assert!(alpha(110, 105) < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn a_file_defined_table_style_draws_its_parts() {
|
||
// Red table background (tblBg), see-through white on banded rows, a 4 pt blue left border and
|
||
// no inside lines: drawn from the definition, not DeckCraft's own look for the style.
|
||
use deckcraft_model::style::{Fill, Line};
|
||
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
|
||
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
|
||
let mut t = Table::new(4, 2, 400.0, 50.0);
|
||
t.style = "{ABCDEF01-2345-4678-9ABC-DEF012345678}".into();
|
||
t.first_row = false;
|
||
t.band_row = true;
|
||
let line = |c: ColorRef, w| TableBorder::Line { line: Line { fill: Some(Fill::solid(c)), width: Some(w), ..Default::default() } };
|
||
let mut whole = TablePartStyle { fill: Some(Fill::None), ..Default::default() };
|
||
whole.borders[0] = Some(line(rgb(0, 0, 255), 4.0));
|
||
whole.borders[4] = Some(TableBorder::Line { line: Line { fill: Some(Fill::None), ..Default::default() } });
|
||
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 255, g: 255, b: 255, a: 51 });
|
||
let band = TablePartStyle { fill: Some(Fill::solid(see_through)), ..Default::default() };
|
||
let def = TableStyleDef {
|
||
id: t.style.clone(),
|
||
name: "Probe".into(),
|
||
parts: vec![(TablePart::WholeTbl, whole), (TablePart::Band1H, band)],
|
||
background: Some(TablePartStyle { fill: Some(Fill::solid(rgb(255, 0, 0))), ..Default::default() }),
|
||
raw: String::new(),
|
||
};
|
||
let tbl = Shape { id: ShapeId(790), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Table(t), ..Default::default() };
|
||
let mut p = deck_with(vec![tbl]);
|
||
p.table_styles.push(def);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let px = |x, y| img.pixel(x, y);
|
||
// Row 1 (band1H): red with 20 % white over it; row 2 (band2H, undefined): plain red.
|
||
let banded = px(300, 125);
|
||
assert!(banded[0] > 250 && (40..65).contains(&banded[1]), "{banded:?}");
|
||
assert_eq!(px(300, 175)[..3], [255, 0, 0]);
|
||
// No line between the rows; a blue left border.
|
||
let edge = px(300, 150);
|
||
assert!(edge[0] > 240, "no inside line: {edge:?}");
|
||
let left = px(100, 175);
|
||
assert!(left[2] > 200 && left[0] < 60, "{left:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_table_fill_shows_behind_cells_without_their_own() {
|
||
// `a:tblPr` solidFill (tdf#127237): cells with no fill show the table's blue.
|
||
use deckcraft_model::style::Fill;
|
||
let mut t = Table::new(2, 2, 200.0, 50.0);
|
||
t.style = "none".into();
|
||
t.background = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 112, 192))));
|
||
for r in 0..2 {
|
||
for c in 0..2 {
|
||
t.cell_mut(r, c).unwrap().fill = Some(Fill::None);
|
||
}
|
||
}
|
||
let tbl = Shape { id: ShapeId(795), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() };
|
||
let p = deck_with(vec![tbl]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]);
|
||
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
|
||
}
|
||
|
||
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
|
||
fn effect_box(effects: deckcraft_model::Effects) -> Image {
|
||
use deckcraft_model::style::{Fill, Line};
|
||
let sh = Shape {
|
||
id: ShapeId(800),
|
||
xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)),
|
||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
|
||
line: Some(Line { fill: Some(Fill::None), ..Default::default() }),
|
||
effects: Some(effects),
|
||
..Default::default()
|
||
};
|
||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||
}
|
||
|
||
fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow {
|
||
deckcraft_model::style::Shadow {
|
||
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)),
|
||
blur,
|
||
dist,
|
||
dir,
|
||
inner: false,
|
||
sx: 1.0,
|
||
sy: 1.0,
|
||
kx: 0.0,
|
||
ky: 0.0,
|
||
align: String::new(),
|
||
rotate_with_shape: false,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shadows_and_glows_spread_like_powerpoints() {
|
||
let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32;
|
||
// Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5
|
||
// px outside the left edge.
|
||
let img = effect_box(deckcraft_model::Effects { outer_shadow: Some(shadow(9.0, 0.0, 0.0)), ..Default::default() });
|
||
for (d, want) in [(1, 144), (3, 204), (5, 238)] {
|
||
assert!((red(&img, 20 - d, 120) - want).abs() <= 6, "outer {d}: {}", red(&img, 20 - d, 120));
|
||
}
|
||
// Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right
|
||
// edges, not the top and left.
|
||
let mut sh = shadow(0.0, 10.0, 45.0);
|
||
sh.inner = true;
|
||
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
|
||
assert!(red(&img, 296, 120) < 20, "right edge {}", red(&img, 296, 120));
|
||
assert!(red(&img, 160, 216) < 20, "bottom edge {}", red(&img, 160, 216));
|
||
assert!(red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250, "top and left stay clear");
|
||
// Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside.
|
||
let mut sh = shadow(9.0, 0.0, 0.0);
|
||
sh.inner = true;
|
||
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
|
||
for (d, want) in [(0, 144), (2, 204), (4, 238)] {
|
||
assert!((red(&img, 20 + d, 120) - want).abs() <= 6, "inner {d}: {}", red(&img, 20 + d, 120));
|
||
}
|
||
// Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10.
|
||
let glow = deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), radius: 10.0 };
|
||
let img = effect_box(deckcraft_model::Effects { glow: Some(glow), ..Default::default() });
|
||
assert!(red(&img, 19, 120) < 20);
|
||
assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120));
|
||
assert!(red(&img, 10, 120) > 245);
|
||
}
|
||
|
||
/// A 400 × 200 text box at (100, 100) holding `text` at 40 pt, warped by `warp`.
|
||
fn wordart(warp: Option<&str>, text: &str) -> Presentation {
|
||
let mut t = TextBody::from_text(text);
|
||
t.paragraphs[0].runs[0].props.size = Some(40.0);
|
||
t.paragraphs[0].props.align = Some(deckcraft_model::text::Align::Center);
|
||
t.body.warp = warp.map(String::from);
|
||
deck_with(vec![Shape { id: ShapeId(700), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), text: Some(t), ..Default::default() }])
|
||
}
|
||
|
||
/// Ink box (x0, y0, x1, y1) of dark pixels, and the top ink row of each column.
|
||
fn ink(img: &Image) -> ((u32, u32, u32, u32), Vec<Option<u32>>) {
|
||
let mut b = (u32::MAX, u32::MAX, 0, 0);
|
||
let mut tops = vec![None; img.width as usize];
|
||
for y in 0..img.height {
|
||
for x in 0..img.width {
|
||
if img.pixel(x, y)[0] < 128 {
|
||
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
|
||
if tops[x as usize].is_none() {
|
||
tops[x as usize] = Some(y);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
(b, tops)
|
||
}
|
||
|
||
#[test]
|
||
fn wordart_arch_up_lifts_the_middle_of_the_line() {
|
||
let o = RenderOpts { scale: 1.0, ..Default::default() };
|
||
let flat = render_slide(&wordart(None, "ARCHED WORDART"), 0, &o);
|
||
let arched = render_slide(&wordart(Some("textArchUp"), "ARCHED WORDART"), 0, &o);
|
||
assert!(diff_px(&flat, &arched) > 1000);
|
||
let ((fx0, _, fx1, _), ftops) = ink(&flat);
|
||
let ((ax0, ay0, ax1, _), atops) = ink(&arched);
|
||
let top_near = |tops: &[Option<u32>], x: u32| (x.saturating_sub(15)..x + 15).filter_map(|i| tops.get(i as usize).copied().flatten()).min();
|
||
// Flat text: the ends and the middle stand on one line.
|
||
let (fl, fm) = (top_near(&ftops, fx0 + 20).unwrap(), top_near(&ftops, (fx0 + fx1) / 2).unwrap());
|
||
assert!(fl.abs_diff(fm) < 4, "flat {fl} {fm}");
|
||
// Arched: the line follows the top of the ellipse inscribed in the box, so the middle is
|
||
// at the box top and the ends sit clearly lower.
|
||
let (al, am, ar) = (top_near(&atops, ax0 + 20).unwrap(), top_near(&atops, (ax0 + ax1) / 2).unwrap(), top_near(&atops, ax1 - 20).unwrap());
|
||
assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}");
|
||
// At the box top (y = 100), give or take the font: Linux's fallback face reaches 79.
|
||
assert!((70..=110).contains(&ay0), "arch top {ay0}");
|
||
}
|
||
|
||
#[test]
|
||
fn wordart_plain_stretches_the_ink_over_the_text_rect() {
|
||
let img = render_slide(&wordart(Some("textPlain"), "PLAIN"), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let ((x0, y0, x1, y1), _) = ink(&img);
|
||
for (got, want) in [(x0, 100), (y0, 100), (x1, 499), (y1, 299)] {
|
||
assert!(got.abs_diff(want) <= 2, "ink box {:?}", (x0, y0, x1, y1));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_or_absent_warps_draw_flat_text() {
|
||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||
// Warm the font database first (other tests load fonts concurrently).
|
||
let _ = render_slide(&wordart(None, "FLAT"), 0, &o);
|
||
let flat = render_slide(&wordart(None, "FLAT"), 0, &o);
|
||
for w in ["textNoShape", "bogus", ""] {
|
||
assert_eq!(diff_px(&flat, &render_slide(&wordart(Some(w), "FLAT"), 0, &o)), 0, "{w}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_warp_preset_renders_text_inside_the_slide() {
|
||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||
for name in
|
||
["textArchUp", "textArchDown", "textCircle", "textButton", "textWave1", "textRingInside", "textCirclePour", "textDeflateInflateDeflate"]
|
||
{
|
||
let mut p = wordart(Some(name), "One line\nTwo line\nThree");
|
||
if let Some(b) = std::sync::Arc::make_mut(&mut p.slides[0]).shapes[0].text.as_mut() {
|
||
b.body.warp_adj = vec![f64::NAN, -1e12];
|
||
}
|
||
let img = render_slide(&p, 0, &o);
|
||
let ((x0, y0, x1, y1), _) = ink(&img);
|
||
assert!(x0 <= x1 && y0 <= y1, "{name}: no ink");
|
||
}
|
||
}
|
||
|
||
/// A placeable WMF, 2 × 1 inch (2880 × 1440 twips): a solid red brush and a rectangle over the
|
||
/// left half, nothing on the right.
|
||
fn half_red_wmf() -> Vec<u8> {
|
||
fn rec(func: u16, params: &[i16]) -> Vec<u8> {
|
||
let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec();
|
||
v.extend(func.to_le_bytes());
|
||
for p in params {
|
||
v.extend(p.to_le_bytes());
|
||
}
|
||
v
|
||
}
|
||
// CREATEBRUSHINDIRECT (solid, COLORREF 0x000000FF = red), SELECTOBJECT 0, RECTANGLE (bottom,
|
||
// right, top, left).
|
||
let body = [rec(0x02FC, &[0, 0xFF, 0, 0]), rec(0x012D, &[0]), rec(0x041B, &[1440, 1440, 0, 0]), rec(0, &[])].concat();
|
||
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
|
||
f.extend(0u16.to_le_bytes());
|
||
for v in [0i16, 0, 2880, 1440] {
|
||
f.extend(v.to_le_bytes());
|
||
}
|
||
f.extend(1440u16.to_le_bytes());
|
||
f.extend(0u32.to_le_bytes());
|
||
f.extend(0u16.to_le_bytes());
|
||
let total = (f.len() + 18 + body.len()) / 2;
|
||
for v in [1u16, 9, 0x300] {
|
||
f.extend(v.to_le_bytes());
|
||
}
|
||
f.extend((total as u32).to_le_bytes());
|
||
f.extend(16u16.to_le_bytes());
|
||
f.extend(0u32.to_le_bytes());
|
||
f.extend(0u16.to_le_bytes());
|
||
f.extend(body);
|
||
f
|
||
}
|
||
|
||
#[test]
|
||
fn metafile_pictures_draw_their_vectors() {
|
||
let mut p = Presentation::default();
|
||
let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf()).expect("media");
|
||
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
|
||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||
for scale in [0.5, 1.0, 3.0] {
|
||
let img = render_slide(&p, 0, &RenderOpts { scale, ..Default::default() });
|
||
let at = |x: f64, y: f64| img.pixel((x * scale) as u32, (y * scale) as u32);
|
||
let left = at(200.0, 200.0);
|
||
assert!(left[0] > 200 && left[1] < 60 && left[2] < 60, "left half red at {scale}: {left:?}");
|
||
let right = at(400.0, 200.0);
|
||
assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "right half empty at {scale}: {right:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn broken_metafiles_draw_nothing_and_never_panic() {
|
||
let wmf = half_red_wmf();
|
||
let mut cases: Vec<Vec<u8>> = (0..wmf.len()).step_by(3).map(|n| wmf[..n].to_vec()).collect();
|
||
cases.push(vec![1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF]);
|
||
let mut huge = wmf.clone();
|
||
huge[10] = 0xFF;
|
||
huge[11] = 0x7F;
|
||
cases.push(huge);
|
||
for bytes in cases {
|
||
let mut p = Presentation::default();
|
||
let media = p.add_media("image1.emf", "image/x-emf", bytes).expect("media");
|
||
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()).expect("media");
|
||
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(), parts: vec![] },
|
||
..Default::default()
|
||
};
|
||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
for (x, y) in [(110, 110), (300, 200), (490, 290)] {
|
||
let px = img.pixel(x, y);
|
||
let red = px[0] > 180 && px[1] < 60 && px[2] < 60;
|
||
assert_eq!(red, with_preview, "({x}, {y}) {px:?}");
|
||
}
|
||
assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame");
|
||
}
|
||
}
|
||
|
||
/// #270: a filled shape half off the slide's left edge, and one wholly below it.
|
||
fn off_slide_deck() -> Presentation {
|
||
let half = Shape {
|
||
id: ShapeId(2700),
|
||
xfrm: Some(Xfrm::new(-100.0, 100.0, 200.0, 100.0)),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||
..Default::default()
|
||
};
|
||
let below = Shape {
|
||
id: ShapeId(2701),
|
||
xfrm: Some(Xfrm::new(400.0, 600.0, 100.0, 60.0)),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
|
||
..Default::default()
|
||
};
|
||
deck_with(vec![half, below])
|
||
}
|
||
|
||
fn near(px: [u8; 4], c: deckcraft_color::Rgba) -> bool {
|
||
(px[0] as i32 - c.r as i32).abs() <= 3 && (px[1] as i32 - c.g as i32).abs() <= 3 && (px[2] as i32 - c.b as i32).abs() <= 3 && px[3] == 255
|
||
}
|
||
|
||
#[test]
|
||
fn shows_exports_and_thumbnails_clip_to_the_slide() {
|
||
let p = off_slide_deck();
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
assert_eq!((img.width, img.height), (960, 540));
|
||
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||
// The on-slide half is there, nothing else is drawn outside the slide.
|
||
assert!(near(img.pixel(50, 150), a1), "{:?}", img.pixel(50, 150));
|
||
assert_eq!(img.pixel(150, 150), [255, 255, 255, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_editor_pasteboard_draws_shapes_off_the_slide() {
|
||
let p = off_slide_deck();
|
||
let extent = slide_extent(&p, 0);
|
||
let region = pasteboard_region((960, 540), 1.0, extent, Region { x: -300, y: -200, w: 1560, h: 1000 });
|
||
assert!(region.x <= -100 && region.y == 0 && region.y as i64 + region.h as i64 >= 660, "{region:?}");
|
||
let opts = RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||
let (img, drawn) = render_slide_drawn(&p, 0, &opts);
|
||
assert_eq!((img.width, img.height), region.size());
|
||
let at = |x: i32, y: i32| img.pixel((x - region.x) as u32, (y - region.y) as u32);
|
||
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||
let a2 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent2);
|
||
// Both halves of the straddling shape, at full strength, and the shape below the slide.
|
||
assert!(near(at(-50, 150), a1), "{:?}", at(-50, 150));
|
||
assert!(near(at(50, 150), a1), "{:?}", at(50, 150));
|
||
assert!(near(at(450, 630), a2), "{:?}", at(450, 630));
|
||
// The pasteboard itself stays clear (the UI's own colour shows through); the slide is white.
|
||
assert_eq!(at(-50, 50), [0, 0, 0, 0]);
|
||
assert_eq!(at(200, 300), [255, 255, 255, 255]);
|
||
// The drawing's reach, in slide space.
|
||
assert!(drawn.x0 <= -100.0 && drawn.y1 >= 660.0 && drawn.x1 >= 960.0, "{drawn:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn master_graphics_off_the_slide_show_on_the_pasteboard() {
|
||
let mut p = Presentation::default();
|
||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||
m.shapes.push(Shape {
|
||
id: ShapeId(2702),
|
||
xfrm: Some(Xfrm::new(-150.0, 300.0, 100.0, 60.0)),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent3)),
|
||
..Default::default()
|
||
});
|
||
let region = pasteboard_region((960, 540), 1.0, slide_extent(&p, 0), Region { x: -300, y: -100, w: 1500, h: 800 });
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() });
|
||
let a3 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent3);
|
||
assert!(near(img.pixel((-100 - region.x) as u32, (330 - region.y) as u32), a3));
|
||
}
|
||
|
||
#[test]
|
||
fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
|
||
let mut sh = Shape { id: ShapeId(2703), xfrm: Some(Xfrm::new(100.0, 500.0, 400.0, 20.0)), ..Default::default() };
|
||
let mut t = TextBody::from_text("one\ntwo\nthree\nfour\nfive");
|
||
for para in &mut t.paragraphs {
|
||
for r in &mut para.runs {
|
||
r.props.size = Some(40.0);
|
||
}
|
||
}
|
||
sh.text = Some(t);
|
||
let p = deck_with(vec![sh]);
|
||
// The box estimate doesn't see the text below the slide; the render does.
|
||
assert!(slide_extent(&p, 0).y1 <= 540.0 + 4.0);
|
||
let (_, drawn) = render_slide_drawn(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||
assert!(drawn.y1 > 600.0, "{drawn:?}");
|
||
// A slide with nothing off it reaches just the slide.
|
||
let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
|
||
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
|
||
}
|
||
|
||
/// A placeable WMF (1440 units per inch, bounds `w` × `h`) holding `records` (function, parameter
|
||
/// bytes).
|
||
fn wmf_with(w: i16, h: i16, records: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||
let mut body = Vec::new();
|
||
for (func, params) in records {
|
||
let mut p = params.clone();
|
||
if p.len() % 2 == 1 {
|
||
p.push(0);
|
||
}
|
||
body.extend(((6 + p.len()) as u32 / 2).to_le_bytes());
|
||
body.extend(func.to_le_bytes());
|
||
body.extend(p);
|
||
}
|
||
body.extend([3, 0, 0, 0, 0, 0]);
|
||
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
|
||
f.extend(0u16.to_le_bytes());
|
||
for v in [0i16, 0, w, h] {
|
||
f.extend(v.to_le_bytes());
|
||
}
|
||
f.extend(1440u16.to_le_bytes());
|
||
f.extend(0u32.to_le_bytes());
|
||
f.extend(0u16.to_le_bytes());
|
||
let total = (f.len() + 18 + body.len()) / 2;
|
||
for v in [1u16, 9, 0x300] {
|
||
f.extend(v.to_le_bytes());
|
||
}
|
||
f.extend((total as u32).to_le_bytes());
|
||
f.extend(16u16.to_le_bytes());
|
||
f.extend(0u32.to_le_bytes());
|
||
f.extend(0u16.to_le_bytes());
|
||
f.extend(body);
|
||
f
|
||
}
|
||
|
||
fn i16s(v: &[i32]) -> Vec<u8> {
|
||
v.iter().flat_map(|x| (*x as i16).to_le_bytes()).collect()
|
||
}
|
||
|
||
/// CREATEFONTINDIRECT (height, escapement, face) then SELECTOBJECT of it (handle 0), transparent
|
||
/// background, and the alignment `ta`.
|
||
fn font_records(height: i32, esc: i32, face: &str, ta: i32) -> Vec<(u16, Vec<u8>)> {
|
||
let mut f = i16s(&[height, 0, esc, esc, 400]);
|
||
f.extend([0, 0, 0, if face.contains("dings") { 2 } else { 0 }, 0, 0, 0, 0]);
|
||
f.extend(face.as_bytes());
|
||
f.push(0);
|
||
vec![(0x02FB, f), (0x012D, i16s(&[0])), (0x0102, i16s(&[1])), (0x012E, i16s(&[ta]))]
|
||
}
|
||
|
||
/// EXTTEXTOUT at (x, y) with an optional dx array.
|
||
fn text_record(x: i32, y: i32, s: &str, dx: &[i32]) -> (u16, Vec<u8>) {
|
||
let mut v = i16s(&[y, x, s.len() as i32, 0]);
|
||
v.extend(s.as_bytes());
|
||
if s.len() % 2 == 1 {
|
||
v.push(0);
|
||
}
|
||
v.extend(i16s(dx));
|
||
(0x0A32, v)
|
||
}
|
||
|
||
/// Bounding box (x0, y0, x1, y1) of the pixels at least half opaque and darker than mid-grey.
|
||
fn mf_ink(img: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> {
|
||
let mut b: Option<(u32, u32, u32, u32)> = None;
|
||
for (x, y, p) in img.enumerate_pixels() {
|
||
if p[3] > 128 && (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 {
|
||
b = Some(match b {
|
||
None => (x, y, x, y),
|
||
Some((a, c, d, e)) => (a.min(x), c.min(y), d.max(x), e.max(y)),
|
||
});
|
||
}
|
||
}
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn metafile_text_ink_lands_at_its_reference_point() {
|
||
// 2 × 1 inch (144 × 72 pt) drawn 288 px wide: 2 px per point. A 36-point "HH", top-left
|
||
// aligned at (72, 18) pt.
|
||
let mut recs = font_records(-720, 0, "Arial", 0);
|
||
recs.push(text_record(1440, 360, "HH", &[]));
|
||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||
let (x0, y0, x1, y1) = mf_ink(&img).expect("text ink");
|
||
assert!((144..156).contains(&x0), "left edge at the reference point: {x0}");
|
||
assert!(x1 > x0 + 40 && x1 < 288, "two capitals wide: {x1}");
|
||
// The cell's top is at 36 px; a capital starts below the cell top and sits on the baseline.
|
||
assert!((38..72).contains(&y0), "cap top below the cell top: {y0}");
|
||
assert!((88..112).contains(&y1), "baseline one ascent below the top: {y1}");
|
||
}
|
||
|
||
#[test]
|
||
fn metafile_text_centre_baseline_and_escapement() {
|
||
// TA_CENTER | TA_BASELINE at (72, 36) pt: the ink is centred on 144 px and sits on 72 px.
|
||
let mut recs = font_records(-720, 0, "Arial", 24 | 6);
|
||
recs.push(text_record(1440, 720, "HH", &[]));
|
||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||
let (x0, _, x1, y1) = mf_ink(&img).expect("ink");
|
||
assert!(((x0 + x1) as i32 / 2 - 144).abs() <= 4, "centred: {x0}..{x1}");
|
||
assert!((69..=74).contains(&y1), "on the baseline: {y1}");
|
||
// Escapement 90°: the string runs up the picture.
|
||
let mut recs = font_records(-360, 900, "Arial", 24);
|
||
recs.push(text_record(1440, 1300, "HHHH", &[]));
|
||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
|
||
assert!(y1 - y0 > 2 * (x1 - x0), "a vertical run: {x0} {y0} {x1} {y1}");
|
||
assert!(x1 <= 146 && y1 <= 132, "drawn up and left of the baseline origin (144, 130): {x1} {y1}");
|
||
}
|
||
|
||
#[test]
|
||
fn metafile_text_follows_its_dx_array() {
|
||
// "HH" with the second H a full inch (144 px) after the first: two clumps of ink, a gap between.
|
||
let mut recs = font_records(-360, 0, "Arial", 24);
|
||
recs.push(text_record(360, 720, "HH", &[1440, 100]));
|
||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||
let col_has_ink = |x: u32| (0..img.height()).any(|y| img.get_pixel(x, y)[3] > 128);
|
||
assert!((36..56).any(col_has_ink), "first H at 36 px");
|
||
assert!((180..200).any(col_has_ink), "second H one inch later");
|
||
assert!(!(70..170).any(col_has_ink), "nothing between them");
|
||
}
|
||
|
||
#[test]
|
||
fn metafile_symbol_text_draws_a_bullet_not_a_box() {
|
||
// Webdings "n" is a filled circle (installed, or mapped to a black circle where it is not), as
|
||
// is Wingdings "l" in a symbol font that is not installed anywhere.
|
||
for (face, s) in [("Webdings", "n"), ("Wingdings Not Installed", "l")] {
|
||
let mut recs = font_records(-1080, 0, face, 0);
|
||
recs.push(text_record(360, 0, s, &[]));
|
||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
|
||
assert!(x1 - x0 > 10 && y1 - y0 > 10, "{face}: a sizeable mark: {x0} {y0} {x1} {y1}");
|
||
let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);
|
||
assert!(img.get_pixel(cx, cy)[3] > 200, "{face}: filled in the middle");
|
||
for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] {
|
||
assert!(img.get_pixel(x, y)[3] < 128, "{face}: round, not a box: corner ({x}, {y})");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn metafile_opaque_text_fills_its_cell() {
|
||
// OPAQUE background in red behind a space.
|
||
let mut recs = font_records(-720, 0, "Arial", 0);
|
||
recs.push((0x0102, i16s(&[2])));
|
||
recs.push((0x0201, 0x0000_00FFu32.to_le_bytes().to_vec()));
|
||
recs.push(text_record(1440, 360, " ", &[]));
|
||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||
let p = img.get_pixel(148, 60);
|
||
assert!(p[0] > 200 && p[1] < 60 && p[3] > 200, "cell filled red: {p:?}");
|
||
assert!(img.get_pixel(140, 60)[3] < 50, "nothing left of the cell");
|
||
}
|
||
|
||
#[test]
|
||
fn metafile_pattern_brush_tiles_and_averages_when_tiny() {
|
||
// A 1-bit 8 × 8 checker (palette white, black), a null pen and a one-inch square.
|
||
let mut dib: Vec<u8> = [40u32, 8, 8].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||
dib.extend(1u16.to_le_bytes());
|
||
dib.extend(1u16.to_le_bytes());
|
||
dib.extend([0u8; 24]);
|
||
dib.extend([255, 255, 255, 0, 0, 0, 0, 0]);
|
||
for r in 0..8 {
|
||
dib.extend([if r % 2 == 0 { 0x55 } else { 0xAA }, 0, 0, 0]);
|
||
}
|
||
let mut brush = i16s(&[5, 0]);
|
||
brush.extend(dib);
|
||
let recs = vec![(0x0142, brush), (0x012D, i16s(&[0])), (0x012D, i16s(&[0x8008])), (0x041B, i16s(&[1440, 1440, 0, 0]))];
|
||
let bytes = wmf_with(1440, 1440, &recs);
|
||
// 4 px per point: a 0.75 pt cell is 3 px.
|
||
let img = crate::metafile::rasterize(&bytes, 288).expect("raster");
|
||
let (a, b) = (img.get_pixel(1, 1), img.get_pixel(4, 1));
|
||
assert!(a[3] > 200 && b[3] > 200);
|
||
assert!((a[0] as i32 - b[0] as i32).abs() > 200, "neighbouring cells alternate: {a:?} {b:?}");
|
||
// Far below one pixel per cell: the average grey.
|
||
let img = crate::metafile::rasterize(&bytes, 32).expect("raster");
|
||
let p = img.get_pixel(16, 16);
|
||
assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}");
|
||
}
|
||
|
||
/// A 400 x 200 group at (100, 100) holding a dashed 4 pt rectangle and a 3 pt line with an
|
||
/// arrowhead, placed in the group's child coordinates by `to_child` (slide point to child point).
|
||
fn outlined_group(child: Xfrm, to_child: impl Fn(f64, f64) -> (f64, f64)) -> Image {
|
||
use deckcraft_model::style::{Dash, LineEnd};
|
||
let ink = |w: f64| deckcraft_model::style::Line { dash: None, ..deckcraft_model::style::Line::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)), w) };
|
||
let place = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||
let ((a, b), (c, d)) = (to_child(x0, y0), to_child(x1, y1));
|
||
Xfrm::new(a, b, c - a, d - b)
|
||
};
|
||
let rect = Shape {
|
||
id: ShapeId(11),
|
||
xfrm: Some(place(140.0, 120.0, 300.0, 200.0)),
|
||
geom: Geom::preset("rect"),
|
||
line: Some(deckcraft_model::style::Line { dash: Some(Dash::Preset { name: "dash".into() }), ..ink(4.0) }),
|
||
..Default::default()
|
||
};
|
||
let end = LineEnd { kind: "triangle".into(), w: "lg".into(), len: "lg".into() };
|
||
let line = Shape {
|
||
id: ShapeId(12),
|
||
xfrm: Some(Xfrm { flip_v: true, ..place(150.0, 170.0, 450.0, 260.0) }),
|
||
geom: Geom::preset("line"),
|
||
line: Some(deckcraft_model::style::Line { tail: Some(end), ..ink(3.0) }),
|
||
..Default::default()
|
||
};
|
||
let pts: Vec<(f64, f64, f32)> = [(160.0, 240.0), (260.0, 285.0)].iter().map(|(x, y)| to_child(*x, *y)).map(|(x, y)| (x, y, 1.0)).collect();
|
||
let ink = Shape {
|
||
id: ShapeId(13),
|
||
xfrm: Some(place(100.0, 100.0, 500.0, 300.0)),
|
||
kind: ShapeKind::Ink {
|
||
strokes: vec![deckcraft_model::InkStroke { points: pts, color: Rgba::rgb(200, 0, 0), width: 5.0, highlighter: false }],
|
||
},
|
||
..Default::default()
|
||
};
|
||
let group = Shape {
|
||
id: ShapeId(10),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||
kind: ShapeKind::Group { children: vec![rect, line, ink], child },
|
||
..Default::default()
|
||
};
|
||
render_slide(&deck_with(vec![group]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||
}
|
||
|
||
fn inked(img: &Image) -> usize {
|
||
img.pixels.as_chunks::<4>().0.iter().filter(|px| px[1] < 200).count()
|
||
}
|
||
|
||
#[test]
|
||
fn group_child_scale_does_not_scale_line_widths() {
|
||
// Children laid out 1:1 with the slide are the reference.
|
||
let reference = outlined_group(Xfrm::new(100.0, 100.0, 400.0, 200.0), |x, y| (x, y));
|
||
let drawn = inked(&reference);
|
||
assert!((500..20_000).contains(&drawn), "the reference draws thin lines: {drawn}");
|
||
// The same drawing in a small legacy child space (scale 100), and one scaled unevenly
|
||
// (100 across, 50 down): same outlines, same line widths, dashes and arrowhead.
|
||
let uniform = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 2.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 100.0));
|
||
let uneven = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 4.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 50.0));
|
||
for (name, img) in [("uniform", &uniform), ("uneven", &uneven)] {
|
||
let (n, d) = (inked(img), differing(&reference, img));
|
||
assert!(n.abs_diff(drawn) * 20 < drawn, "{name}: {n} inked pixels against {drawn}");
|
||
assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_picture_casts_its_shadow_from_its_pixels() {
|
||
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
|
||
// logo's shadow, not its frame's. Here the left half of the image is transparent.
|
||
let png = {
|
||
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 160, 20, 255]) });
|
||
let mut out = std::io::Cursor::new(vec![]);
|
||
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||
out.into_inner()
|
||
};
|
||
let shadow = deckcraft_model::style::Shadow {
|
||
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
|
||
blur: 0.0,
|
||
dist: 20.0,
|
||
dir: 90.0,
|
||
inner: false,
|
||
sx: 1.0,
|
||
sy: 1.0,
|
||
kx: 0.0,
|
||
ky: 0.0,
|
||
align: String::new(),
|
||
rotate_with_shape: false,
|
||
};
|
||
for as_fill in [false, true] {
|
||
let mut p = Presentation::default();
|
||
let media = p.add_media("logo.png", "image/png", png.clone()).expect("media");
|
||
let pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||
let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() };
|
||
if as_fill {
|
||
sh.fill = Some(Fill::Picture(pf));
|
||
sh.line = Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
|
||
} else {
|
||
sh.kind = ShapeKind::Picture { fill: pf };
|
||
}
|
||
sh.effects = Some(deckcraft_model::Effects { outer_shadow: Some(shadow.clone()), ..Default::default() });
|
||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
// Below the picture, 10 pt down: shadow under the opaque right half only.
|
||
let left = img.pixel(200, 310);
|
||
let right = img.pixel(400, 310);
|
||
assert!(left[0] > 240, "no shadow under the transparent half ({as_fill}): {left:?}");
|
||
assert!(right[0] < 40, "shadow under the opaque half ({as_fill}): {right:?}");
|
||
// The transparent half shows the slide, not a shadow behind it.
|
||
assert!(img.pixel(200, 200)[0] > 240, "{as_fill}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn text_in_a_scaled_group_keeps_its_point_size() {
|
||
// A group's child coordinates scale its shapes' boxes, never their type: a 24 pt word in a
|
||
// group whose child space is 1/1600 of its size (as old decks write them) or 4 times it is
|
||
// 24 pt on the slide, and inside its box (it used to grow until the slide turned black).
|
||
let draw = |scale: f64| {
|
||
let mut p = Presentation::default();
|
||
let mut body = TextBody::from_text("Hello");
|
||
body.paragraphs[0].runs[0].props.size = Some(24.0);
|
||
body.paragraphs[0].runs[0].props.fill = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK)));
|
||
let child = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale)), text: Some(body), ..Default::default() };
|
||
let g = Shape {
|
||
id: ShapeId(600),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||
kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale) },
|
||
..Default::default()
|
||
};
|
||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![g];
|
||
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||
};
|
||
let ((_, y0, _, y1), _) = ink(&draw(1.0));
|
||
let plain = y1 - y0;
|
||
assert!(plain > 10 && plain < 30, "24 pt word ink height {plain}");
|
||
for scale in [1600.0, 4.0, 0.25] {
|
||
let ((x0, sy0, x1, sy1), _) = ink(&draw(scale));
|
||
assert!((sy1 - sy0).abs_diff(plain) <= 1, "scale {scale}: ink height {} vs {plain}", sy1 - sy0);
|
||
assert!(x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300, "scale {scale}: ink inside the box");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_fill_overlay_blends_over_the_fill() {
|
||
// `a:fillOverlay blend="mult"`: a 50% grey multiplied over a light fill darkens it; "screen"
|
||
// lightens a dark one; the overlay stays inside the shape.
|
||
use deckcraft_model::style::FillOverlay;
|
||
let draw = |base: [u8; 3], blend: &str| {
|
||
let mut p = Presentation::default();
|
||
let mut sh = Shape {
|
||
id: ShapeId(600),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(base[0], base[1], base[2])))),
|
||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||
..Default::default()
|
||
};
|
||
sh.effects = Some(deckcraft_model::Effects {
|
||
fill_overlay: Some(FillOverlay { blend: blend.into(), fill: Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(128, 128, 128))) }),
|
||
..Default::default()
|
||
});
|
||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
(img.pixel(200, 150), img.pixel(50, 50))
|
||
};
|
||
let (mult, outside) = draw([200, 100, 50], "mult");
|
||
assert!((mult[0] as i32 - 100).abs() <= 3 && (mult[1] as i32 - 50).abs() <= 3 && (mult[2] as i32 - 25).abs() <= 3, "{mult:?}");
|
||
assert_eq!(&outside[..3], &[255, 255, 255], "nothing outside the shape");
|
||
let (screen, _) = draw([0, 0, 0], "screen");
|
||
assert!((screen[0] as i32 - 128).abs() <= 3, "{screen:?}");
|
||
let (over, _) = draw([200, 100, 50], "over");
|
||
assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn dotted_and_wavy_underlines_draw_their_pattern() {
|
||
// Underlined spaces only, so the underline is the only ink: a red line under " ".
|
||
let row_runs = |u: &str| {
|
||
let mut sh = Shape { id: ShapeId(760), xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 300.0)), text_box: true, ..Default::default() };
|
||
let mut t = TextBody::from_text(" ");
|
||
let props = &mut t.paragraphs[0].runs[0].props;
|
||
props.size = Some(96.0);
|
||
props.underline = Some(u.into());
|
||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||
sh.text = Some(t);
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let red = |x: u32, y: u32| {
|
||
let px = img.pixel(x, y);
|
||
px[0] > 150 && px[1] < 120
|
||
};
|
||
// The ink's rows, and the number of separate red stretches along the most inked row.
|
||
let rows: Vec<u32> = (0..300).filter(|&y| (0..900).any(|x| red(x, y))).collect();
|
||
let best = rows.iter().copied().max_by_key(|&y| (0..900).filter(|&x| red(x, y)).count()).unwrap_or(0);
|
||
let stretches = (1..900).filter(|&x| red(x, best) && !red(x - 1, best)).count();
|
||
(rows.len(), stretches)
|
||
};
|
||
let (sng_rows, sng_runs) = row_runs("sng");
|
||
assert_eq!(sng_runs, 1, "a single line is one stretch");
|
||
let (_, dotted_runs) = row_runs("dotted");
|
||
assert!(dotted_runs > 10, "dots: {dotted_runs}");
|
||
let (wavy_rows, _) = row_runs("wavy");
|
||
assert!(wavy_rows > sng_rows, "a wave spans more rows ({wavy_rows}) than a single line ({sng_rows})");
|
||
}
|
||
|
||
#[test]
|
||
fn east_asian_vertical_text_stands_ideographs_upright() {
|
||
// "一" is a horizontal stroke. In eaVert text it stays horizontal (upright) while the line runs
|
||
// down the shape; in vert it lies on its side like Latin text.
|
||
let ink = |dir: deckcraft_model::text::TextDir| {
|
||
let mut sh = Shape { id: ShapeId(770), xfrm: Some(Xfrm::new(100.0, 50.0, 200.0, 400.0)), text_box: true, ..Default::default() };
|
||
let mut t = TextBody::from_text("一");
|
||
t.body.vert = Some(dir);
|
||
let props = &mut t.paragraphs[0].runs[0].props;
|
||
props.size = Some(80.0);
|
||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
|
||
sh.text = Some(t);
|
||
let p = deck_with(vec![sh]);
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
|
||
for y in 0..540 {
|
||
for x in 0..960 {
|
||
if img.pixel(x, y)[0] < 128 {
|
||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||
}
|
||
}
|
||
}
|
||
assert!(x1 >= x0 && y1 >= y0, "no ink for {dir:?}");
|
||
(x1 - x0, y1 - y0)
|
||
};
|
||
let (w, h) = ink(deckcraft_model::text::TextDir::EaVertical);
|
||
assert!(w > h * 3, "eaVert: upright stroke {w}x{h}");
|
||
let (w, h) = ink(deckcraft_model::text::TextDir::Vertical);
|
||
assert!(h > w * 3, "vert: stroke on its side {w}x{h}");
|
||
}
|
||
|
||
#[test]
|
||
fn placed_shapes_give_the_click_boxes_of_a_slide_in_drawing_order() {
|
||
use deckcraft_model::text::{Action, Hyperlink};
|
||
let link =
|
||
Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None };
|
||
let rect = |id: u32, x: f64, click: Option<Hyperlink>| Shape {
|
||
id: ShapeId(id),
|
||
xfrm: Some(Xfrm::new(x, 50.0, 100.0, 40.0)),
|
||
click,
|
||
..Default::default()
|
||
};
|
||
let group = Shape {
|
||
id: ShapeId(10),
|
||
xfrm: Some(Xfrm::new(300.0, 50.0, 200.0, 40.0)),
|
||
click: Some(link.clone()),
|
||
kind: ShapeKind::Group { children: vec![rect(11, 0.0, None), rect(12, 100.0, None)], child: Xfrm::new(0.0, 0.0, 200.0, 40.0) },
|
||
..Default::default()
|
||
};
|
||
let hidden = Shape { hidden: true, ..rect(13, 0.0, Some(link.clone())) };
|
||
let p = deck_with(vec![rect(1, 0.0, None), rect(2, 150.0, Some(link.clone())), hidden, group]);
|
||
let placed = place_slide(&p, 0);
|
||
// Back to front, a group before its children; the hidden shape is not there.
|
||
let ids: Vec<u32> = placed.shapes.iter().map(|s| s.id.0).collect();
|
||
assert_eq!(ids, vec![1, 2, 10, 11, 12]);
|
||
// Only the shapes with an action carry a link.
|
||
let linked: Vec<u32> = placed.shapes.iter().filter(|s| s.link.is_some()).map(|s| s.id.0).collect();
|
||
assert_eq!(linked, vec![2, 10]);
|
||
// The children know their group, whose box covers them.
|
||
let group = &placed.shapes[2];
|
||
assert!(group.is_group && group.parent.is_none());
|
||
for child in &placed.shapes[3..] {
|
||
assert_eq!(child.parent, Some(2));
|
||
assert!(child.rect.x0 >= group.rect.x0 - 0.01 && child.rect.x1 <= group.rect.x1 + 0.01);
|
||
}
|
||
// Shape actions come out as links owned by their shape, not as text links.
|
||
assert!(placed.links.iter().all(|l| !l.text));
|
||
assert_eq!(placed.links.iter().map(|l| l.shape.0).collect::<Vec<_>>(), vec![2, 10]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_text_hyperlink_belongs_to_the_shape_that_holds_the_text() {
|
||
use deckcraft_model::text::{Action, Hyperlink};
|
||
let mut body = TextBody::from_text("click here");
|
||
for r in &mut body.paragraphs[0].runs {
|
||
r.props.link = Some(Hyperlink {
|
||
action: Action::FirstSlide,
|
||
tooltip: String::new(),
|
||
highlight_click: false,
|
||
sound: None,
|
||
stop_sound: false,
|
||
zoom_return: None,
|
||
});
|
||
}
|
||
let sh = Shape { id: ShapeId(7), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 60.0)), text: Some(body), ..Default::default() };
|
||
let placed = place_slide(&deck_with(vec![sh]), 0);
|
||
assert!(placed.links.iter().any(|l| l.text && l.shape == ShapeId(7)));
|
||
assert!(placed.shapes.iter().all(|s| s.link.is_none()), "a text link is not the shape's own action");
|
||
}
|
||
|
||
#[test]
|
||
fn metafile_intersect_clip_rect_hides_the_rest_of_the_picture() {
|
||
let rec = |typ: u32, payload: &[u8]| {
|
||
let mut v = typ.to_le_bytes().to_vec();
|
||
v.extend(((8 + payload.len()) as u32).to_le_bytes());
|
||
v.extend(payload);
|
||
v
|
||
};
|
||
let rect = |l: i32, t: i32, r: i32, b: i32| [l, t, r, b].into_iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
|
||
let mut body = rec(30, &rect(0, 0, 50, 50));
|
||
body.extend(rec(43, &rect(0, 0, 100, 50)));
|
||
let eof = rec(14, &[0; 12]);
|
||
let total = 88 + body.len() + eof.len();
|
||
let mut file = vec![0u8; 88];
|
||
file[0..4].copy_from_slice(&1u32.to_le_bytes());
|
||
file[4..8].copy_from_slice(&88u32.to_le_bytes());
|
||
file[8..24].copy_from_slice(&rect(0, 0, 100, 50));
|
||
file[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
|
||
file[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes());
|
||
file[48..52].copy_from_slice(&(total as u32).to_le_bytes());
|
||
file[52..56].copy_from_slice(&2u32.to_le_bytes());
|
||
file[56..58].copy_from_slice(&1u16.to_le_bytes());
|
||
file.extend(body);
|
||
file.extend(eof);
|
||
let img = crate::metafile::rasterize(&file, 100).expect("raster");
|
||
let opaque = |x: u32, y: u32| img.get_pixel(x, y)[3] > 200;
|
||
assert!(opaque(20, 25), "the clipped half is drawn");
|
||
assert!(!opaque(80, 25), "past the clip the picture stays clear, pixel {:?}", img.get_pixel(80, 25));
|
||
}
|
||
|
||
#[test]
|
||
fn tiny_chart_does_not_blank_the_slide() {
|
||
// A chart a few points wide used to panic in `f64::clamp` (gutter min above max), and the
|
||
// panic guard then blanked the whole slide. Render directly, without the guard.
|
||
let red = Shape {
|
||
id: ShapeId(800),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||
..Default::default()
|
||
};
|
||
let mut shapes = vec![red];
|
||
for (i, k) in [ChartType::Column, ChartType::Bar, ChartType::Line, ChartType::Combo, ChartType::Scatter].iter().enumerate() {
|
||
shapes.push(Shape {
|
||
id: ShapeId(801 + i as u32),
|
||
xfrm: Some(Xfrm::new(500.0 + i as f64 * 5.0, 300.0, 0.9, 0.9)),
|
||
kind: ShapeKind::Chart(Box::new(Chart::sample(*k))),
|
||
..Default::default()
|
||
});
|
||
}
|
||
let p = deck_with(shapes);
|
||
let img = RENDERER.with(|r| r.borrow_mut().slide(&p, &p.slides[0], 0, &RenderOpts { scale: 1.0, ..Default::default() }));
|
||
let px = img.pixel(200, 150);
|
||
assert_ne!(px, [255, 255, 255, 255], "the other shape must still draw");
|
||
assert_eq!(crate::chart::clamp_soft(5.0, 10.5, 0.276), 0.276);
|
||
}
|
||
|
||
/// Shapes with depth under turned cameras (an `extrusionH` and, on two, an `extrusionClr`).
|
||
fn extruded_deck() -> Presentation {
|
||
let mk = |id: u32, x: f64, preset: &str, raw: &str| Shape {
|
||
id: ShapeId(id),
|
||
xfrm: Some(Xfrm::new(x, 170.0, 170.0, 130.0)),
|
||
geom: Geom::preset(preset),
|
||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||
effects: Some(deckcraft_model::Effects { raw3d: Some(raw.into()), ..Default::default() }),
|
||
..Default::default()
|
||
};
|
||
deck_with(vec![
|
||
mk(
|
||
700,
|
||
40.0,
|
||
"rect",
|
||
r#"<a:scene3d><a:camera prst="isometricOffAxis2Left"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="381000"/>"#,
|
||
),
|
||
mk(
|
||
701,
|
||
280.0,
|
||
"ellipse",
|
||
r#"<a:scene3d><a:camera prst="isometricTopUp"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="254000" contourW="63500"><a:extrusionClr><a:srgbClr val="C00000"/></a:extrusionClr><a:contourClr><a:srgbClr val="FFC000"/></a:contourClr></a:sp3d>"#,
|
||
),
|
||
mk(
|
||
702,
|
||
520.0,
|
||
"star5",
|
||
r#"<a:scene3d><a:camera prst="perspectiveContrastingLeftFacing"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="508000"><a:extrusionClr><a:schemeClr val="accent2"/></a:extrusionClr></a:sp3d>"#,
|
||
),
|
||
mk(
|
||
703,
|
||
760.0,
|
||
"rect",
|
||
r#"<a:scene3d><a:camera prst="orthographicFront"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="381000" contourW="76200" prstMaterial="metal"><a:contourClr><a:srgbClr val="00B050"/></a:contourClr></a:sp3d>"#,
|
||
),
|
||
])
|
||
}
|
||
|
||
#[test]
|
||
fn contours_band_the_outline_in_their_colour() {
|
||
let img = render_slide(&extruded_deck(), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let n = |x0: u32, x1: u32, f: &dyn Fn([u8; 4]) -> bool| {
|
||
let mut k = 0;
|
||
for y in 0..img.height {
|
||
for x in x0..x1.min(img.width) {
|
||
let i = ((y * img.width + x) * 4) as usize;
|
||
if img.pixels.get(i..i + 4).and_then(|p| <[u8; 4]>::try_from(p).ok()).is_some_and(f) {
|
||
k += 1;
|
||
}
|
||
}
|
||
}
|
||
k
|
||
};
|
||
// The ellipse's yellow contour and the flat rectangle's green one.
|
||
assert!(n(260, 480, &|p| p[0] > 200 && (150..230).contains(&p[1]) && p[2] < 80) > 200);
|
||
assert!(n(740, 960, &|p| p[0] < 80 && p[1] > 120 && p[2] < 120) > 300);
|
||
}
|
||
|
||
#[test]
|
||
fn extrusions_show_their_sides_under_a_turned_camera() {
|
||
let p = extruded_deck();
|
||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
if let Ok(path) = std::env::var("DECKCRAFT_PNG_OUT") {
|
||
let _ = std::fs::write(path, img.to_png());
|
||
}
|
||
let count = |x0: u32, x1: u32, f: &dyn Fn([u8; 4]) -> bool| {
|
||
(x0..x1).flat_map(|x| (100..400).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count()
|
||
};
|
||
// The red sides of the ellipse; the dark accent-1 sides of the cube; none for a front camera.
|
||
assert!(count(250, 500, &|c| c[0] > 100 && c[1] < 40 && c[2] < 40) > 300, "red extrusion");
|
||
let flat = render_slide(&deck_with(vec![]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
let ink = |img: &Image, x0: u32, x1: u32| {
|
||
(x0..x1).flat_map(|x| (100..400).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y) != [255, 255, 255, 255]).count()
|
||
};
|
||
let mut no_depth = extruded_deck();
|
||
for sh in &mut std::sync::Arc::make_mut(&mut no_depth.slides[0]).shapes {
|
||
if let Some(fx) = sh.effects.as_mut() {
|
||
fx.raw3d = fx.raw3d.as_ref().map(|r| r.replace(" extrusionH=", " x="));
|
||
}
|
||
}
|
||
let thin = render_slide(&no_depth, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
assert!(ink(&img, 0, 260) > ink(&thin, 0, 260) + 1000, "the cube's sides add ink");
|
||
assert_eq!(ink(&img, 740, 960), ink(&thin, 740, 960), "a front camera shows no sides");
|
||
assert_eq!(ink(&flat, 0, 960), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn table_rows_below_the_slide_are_not_laid_out_and_the_slide_looks_the_same() {
|
||
// A fuzzed deck's 200 x 200 table of long text took 70 s to draw, most of it off the slide.
|
||
let table = |rows: usize| {
|
||
let mut t = Table::new(rows, 2, 400.0, 30.0);
|
||
for r in 0..rows {
|
||
for c in 0..2 {
|
||
t.cell_mut(r, c).unwrap().text = TextBody::from_text(&format!("Row {r} col {c}"));
|
||
}
|
||
}
|
||
Shape { id: ShapeId(611), xfrm: Some(Xfrm::new(50.0, 50.0, 400.0, 30.0 * rows as f64)), kind: ShapeKind::Table(t), ..Default::default() }
|
||
};
|
||
let opts = RenderOpts { scale: 0.5, ..Default::default() };
|
||
crate::table::TEXT_CELLS_DRAWN.with(|n| n.set(0));
|
||
let big = render_slide(&deck_with(vec![table(500)]), 0, &opts);
|
||
let drawn = crate::table::TEXT_CELLS_DRAWN.with(|n| n.get());
|
||
assert!(drawn > 2 * 10 && drawn < 2 * 40, "{drawn} cells drawn");
|
||
// 30 rows already run past the slide's bottom: the same picture.
|
||
let small = render_slide(&deck_with(vec![table(30)]), 0, &opts);
|
||
assert_eq!((big.width, big.height), (small.width, small.height));
|
||
let diff = (0..big.height).flat_map(|y| (0..big.width).map(move |x| (x, y))).filter(|&(x, y)| big.pixel(x, y) != small.pixel(x, y)).count();
|
||
assert_eq!(diff, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn office_2016_charts_draw_their_own_layouts() {
|
||
let chart = |kind: ChartType, vals: &[f64], subtotals: Vec<usize>| {
|
||
let c = Chart {
|
||
kind,
|
||
title: None,
|
||
legend: None,
|
||
gridlines: false,
|
||
subtotals,
|
||
categories: (0..vals.len()).map(|i| format!("C{i}")).collect(),
|
||
series: vec![deckcraft_model::chart::Series { values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }],
|
||
..Default::default()
|
||
};
|
||
let sh = Shape { id: ShapeId(621), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||
};
|
||
let ink = |img: &crate::Image, x: u32, y: u32| {
|
||
let p = img.pixel(x, y);
|
||
p[0] < 235 || p[1] < 235 || p[2] < 235
|
||
};
|
||
// Waterfall: the decrease floats (nothing under it near the axis), the total stands on it.
|
||
let img = chart(ChartType::Waterfall, &[100.0, 40.0, -25.0, 115.0], vec![3]);
|
||
let plot_bottom = (300..530).rev().find(|&y| ink(&img, 120, y)).unwrap_or(500);
|
||
let band = |i: f64| (80.0 + (900.0 - 80.0) * (i + 0.5) / 4.0) as u32;
|
||
assert!(!ink(&img, band(2.0), plot_bottom - 20), "the decrease bar reaches the axis");
|
||
assert!(ink(&img, band(3.0), plot_bottom - 20), "the total bar doesn't stand on the axis");
|
||
assert!(ink(&img, band(1.0), 100), "the increase bar is missing");
|
||
// Funnel, treemap and sunburst fill the middle of the chart.
|
||
for kind in [ChartType::Funnel, ChartType::Treemap, ChartType::Sunburst] {
|
||
let img = chart(kind, &[50.0, 30.0, 20.0], vec![]);
|
||
let covered = (0..540).step_by(10).flat_map(|y| (0..960).step_by(10).map(move |x| (x, y))).filter(|&(x, y)| ink(&img, x, y)).count();
|
||
assert!(covered > 1000, "{kind:?}: {covered} samples inked");
|
||
}
|
||
}
|
||
|
||
/// Histograms draw touching columns of their bins, Pareto charts add the cumulative line, and
|
||
/// box and whisker charts draw a box per group and series.
|
||
#[test]
|
||
fn histogram_pareto_and_box_charts_draw() {
|
||
let shot = |c: Chart| {
|
||
let sh = Shape { id: ShapeId(622), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||
};
|
||
let inked = |img: &crate::Image| {
|
||
(0..540)
|
||
.step_by(6)
|
||
.flat_map(|y| (0..960).step_by(6).map(move |x| (x, y)))
|
||
.filter(|&(x, y)| {
|
||
let p = img.pixel(x, y);
|
||
p[0] < 200 || p[1] < 200 || p[2] < 200
|
||
})
|
||
.count()
|
||
};
|
||
let mut empty = Chart::sample(ChartType::Histogram);
|
||
empty.series[0].values.clear();
|
||
let blank = inked(&shot(empty));
|
||
for kind in [ChartType::Histogram, ChartType::Pareto, ChartType::BoxWhisker] {
|
||
let img = shot(Chart::sample(kind));
|
||
if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") {
|
||
let _ = std::fs::write(format!("{dir}/{kind:?}.png"), img.to_png());
|
||
}
|
||
let n = inked(&img);
|
||
assert!(n > blank + 400, "{kind:?}: {n} inked samples (blank {blank})");
|
||
}
|
||
}
|
||
|
||
/// Multi-level treemaps and sunbursts draw their groups: the treemap's group banners and the
|
||
/// sunburst's inner ring of groups, coloured by top-level group.
|
||
#[test]
|
||
fn multilevel_treemap_and_sunburst_draw_their_groups() {
|
||
for kind in [ChartType::Treemap, ChartType::Sunburst] {
|
||
let c = Chart::sample(kind);
|
||
let sh = Shape { id: ShapeId(623), 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() });
|
||
if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") {
|
||
let _ = std::fs::write(format!("{dir}/{kind:?}-levels.png"), img.to_png());
|
||
}
|
||
// Distinct colours: one hue per top-level group plus lighter children.
|
||
let mut seen: Vec<[u8; 3]> = Vec::new();
|
||
for y in (60..520).step_by(8) {
|
||
for x in (40..920).step_by(8) {
|
||
let p = img.pixel(x, y);
|
||
let c = [p[0] / 16, p[1] / 16, p[2] / 16];
|
||
if c != [15, 15, 15] && !seen.contains(&c) {
|
||
seen.push(c);
|
||
}
|
||
}
|
||
}
|
||
assert!(seen.len() >= 6, "{kind:?}: {} colours", seen.len());
|
||
}
|
||
}
|
||
|
||
/// A region map without drawable geography falls back to coloured tiles on the value scale.
|
||
#[test]
|
||
fn region_map_draws_tiles_on_its_colour_scale() {
|
||
let names = ["Brest", "Vitebsk", "Gomel", "Grodno", "Minsk", "Mogilev", "Minsk City"];
|
||
let vals = [3.1, 2.2, 2.9, 4.8, 6.0, 1.4, 9.5];
|
||
let c = Chart {
|
||
kind: ChartType::RegionMap,
|
||
title: Some("Sales by region".into()),
|
||
categories: names.iter().map(|s| s.to_string()).collect(),
|
||
series: vec![deckcraft_model::chart::Series { name: "Sales".into(), values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }],
|
||
data_labels: true,
|
||
..Default::default()
|
||
};
|
||
let sh = Shape { id: ShapeId(624), 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() });
|
||
if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") {
|
||
let _ = std::fs::write(format!("{dir}/RegionMap-tiles.png"), img.to_png());
|
||
}
|
||
let mut seen: Vec<[u8; 3]> = Vec::new();
|
||
for y in (60..520).step_by(6) {
|
||
for x in (40..920).step_by(6) {
|
||
let p = img.pixel(x, y);
|
||
let c = [p[0] / 16, p[1] / 16, p[2] / 16];
|
||
if c != [15, 15, 15] && !seen.contains(&c) {
|
||
seen.push(c);
|
||
}
|
||
}
|
||
}
|
||
assert!(seen.len() >= 5, "{} colours", seen.len());
|
||
}
|
||
|
||
/// Radar charts are polar, stock charts draw high-low lines (and up/down bars), and a combo
|
||
/// chart draws its line series over its columns.
|
||
#[test]
|
||
fn radar_stock_and_combo_draw_their_own_layouts() {
|
||
let mut ohlc = Chart::sample(ChartType::Stock);
|
||
ohlc.series.insert(
|
||
0,
|
||
deckcraft_model::chart::Series {
|
||
name: "Open".into(),
|
||
values: [44.0, 25.0, 38.0, 50.0, 35.0].iter().map(|v| Some(*v)).collect(),
|
||
..Default::default()
|
||
},
|
||
);
|
||
ohlc.up_down_bars = true;
|
||
let charts = [
|
||
("Radar", Chart::sample(ChartType::Radar)),
|
||
("FilledRadar", Chart::sample(ChartType::FilledRadar)),
|
||
("Stock", Chart::sample(ChartType::Stock)),
|
||
("StockOHLC", ohlc),
|
||
("Combo", Chart::sample(ChartType::Combo)),
|
||
];
|
||
for (name, c) in charts {
|
||
let sh = Shape {
|
||
id: ShapeId(625),
|
||
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
|
||
kind: ShapeKind::Chart(Box::new(c.clone())),
|
||
..Default::default()
|
||
};
|
||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||
if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") {
|
||
let _ = std::fs::write(format!("{dir}/{name}.png"), img.to_png());
|
||
}
|
||
let ink = |x: u32, y: u32| img.pixel(x, y)[..3] != [255, 255, 255];
|
||
match name {
|
||
// The radar's top spoke runs straight up from the centre; a line chart has nothing there.
|
||
"Radar" | "FilledRadar" => assert!((150..260).any(|y| ink(480, y)), "{name}"),
|
||
// No series lines: well right of the first category there is only gridline grey.
|
||
"Stock" | "StockOHLC" => {
|
||
let coloured = (60..480).step_by(2).flat_map(|y| (40..920).step_by(4).map(move |x| (x, y))).any(|(x, y)| {
|
||
let p = img.pixel(x, y);
|
||
p[2] > p[0].saturating_add(60)
|
||
});
|
||
assert!(!coloured || name == "Stock", "{name}: series drawn as lines");
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn charts_in_degenerate_frames_draw_without_panicking() {
|
||
// A chart frame squashed to no width (cx="0" in a generated file) made the value labels'
|
||
// gutter clamp panic (its upper bound, a share of the width, fell below its lower bound).
|
||
// Every chart type and legend position must survive any frame size.
|
||
let p = Presentation::default();
|
||
let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||
let kinds = [
|
||
ChartType::Column,
|
||
ChartType::StackedColumn,
|
||
ChartType::PercentColumn,
|
||
ChartType::Bar,
|
||
ChartType::StackedBar,
|
||
ChartType::PercentBar,
|
||
ChartType::Line,
|
||
ChartType::LineMarkers,
|
||
ChartType::StackedLine,
|
||
ChartType::Area,
|
||
ChartType::StackedArea,
|
||
ChartType::Pie,
|
||
ChartType::Doughnut,
|
||
ChartType::Scatter,
|
||
ChartType::Bubble,
|
||
ChartType::Radar,
|
||
ChartType::FilledRadar,
|
||
ChartType::Stock,
|
||
ChartType::Surface,
|
||
ChartType::Histogram,
|
||
ChartType::Pareto,
|
||
ChartType::BoxWhisker,
|
||
ChartType::Waterfall,
|
||
ChartType::Funnel,
|
||
ChartType::Treemap,
|
||
ChartType::Sunburst,
|
||
ChartType::Combo,
|
||
];
|
||
let mut ctx = vello_cpu::RenderContext::new(64, 64);
|
||
for k in kinds {
|
||
for (w, h) in [(0.0, 403.0), (403.0, 0.0), (0.0, 0.0), (2.0, 400.0), (400.0, 2.0), (12.0, 12.0)] {
|
||
for legend in [None, Some("b"), Some("t"), Some("r"), Some("l"), Some("tr")] {
|
||
let mut c = Chart::sample(k);
|
||
c.legend = legend.map(String::from);
|
||
crate::chart::draw(&mut ctx, &rctx, &c, kurbo::Affine::IDENTITY, w, h);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Renders the EMF at `$DECKCRAFT_EMF_IN` to `$DECKCRAFT_EMF_PNG` (a look-at helper; does nothing
|
||
/// without them).
|
||
#[test]
|
||
fn render_an_emf_file_to_look_at() {
|
||
let (Ok(src), Ok(out)) = (std::env::var("DECKCRAFT_EMF_IN"), std::env::var("DECKCRAFT_EMF_PNG")) else { return };
|
||
let Ok(bytes) = std::fs::read(&src) else { return };
|
||
let mut p = Presentation::default();
|
||
let media = p.add_media("image1.emf", "image/x-emf", bytes).expect("media");
|
||
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(40.0, 40.0, 840.0, 400.0)), kind: ShapeKind::Picture { fill }, ..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 _ = std::fs::write(out, img.to_png());
|
||
}
|
||
|
||
#[test]
|
||
fn single_series_column_chart_varies_colours_by_point() {
|
||
let render = |vary: bool| {
|
||
let mut c = Chart::sample(ChartType::Column);
|
||
c.series.truncate(1);
|
||
c.vary_colors = vary;
|
||
let sh =
|
||
Shape { id: ShapeId(800), xfrm: Some(Xfrm::new(50.0, 50.0, 400.0, 300.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }).pixels
|
||
};
|
||
assert_ne!(render(true), render(false));
|
||
}
|
||
|
||
/// A 1024×1024 PNG of 2 px wide black and white vertical stripes.
|
||
fn fine_stripes_png() -> Vec<u8> {
|
||
let img =
|
||
image::RgbaImage::from_fn(1024, 1024, |x, _| if (x / 2) % 2 == 0 { image::Rgba([0, 0, 0, 255]) } else { image::Rgba([255, 255, 255, 255]) });
|
||
let mut out = std::io::Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
|
||
out.into_inner()
|
||
}
|
||
|
||
#[test]
|
||
fn a_cropped_picture_is_decoded_at_the_resolution_of_the_whole_image() {
|
||
// The top-left sixteenth of the image in a 64 pt box: 64 image pixels over 64 output pixels.
|
||
// Decoding at the box's size instead of the whole image's blurs the stripes to grey (#762).
|
||
let mut p = Presentation::default();
|
||
let media = p.add_media("stripes.png", "image/png", fine_stripes_png()).expect("media");
|
||
let fill = deckcraft_model::style::PictureFill { media, crop: [0.0, 0.0, 15.0 / 16.0, 15.0 / 16.0], ..Default::default() };
|
||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 64.0, 64.0)), kind: ShapeKind::Picture { fill }, ..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 (mut sharp, mut total) = (0, 0);
|
||
for y in 110..154 {
|
||
for x in 110..154 {
|
||
let v = img.pixel(x, y)[0];
|
||
total += 1;
|
||
if !(48..=208).contains(&v) {
|
||
sharp += 1;
|
||
}
|
||
}
|
||
}
|
||
assert!(sharp * 10 >= total * 9, "the stripes stay sharp: {sharp} of {total} pixels black or white");
|
||
}
|
||
|
||
#[test]
|
||
fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
|
||
use deckcraft_model::text::{Bullet, Paragraph};
|
||
let mut para = Paragraph::default();
|
||
para.props.bullet = Some(Bullet::Char { char: "•".into() });
|
||
para.props.margin_left = Some(24.0);
|
||
para.props.indent = Some(-24.0);
|
||
para.end_props.size = Some(32.0);
|
||
let sh = Shape {
|
||
id: ShapeId(901),
|
||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 120.0)),
|
||
text: Some(TextBody { paragraphs: vec![para], ..Default::default() }),
|
||
..Default::default()
|
||
};
|
||
let p = deck_with(vec![sh]);
|
||
let render = |edit, editing_text| render_slide(&p, 0, &RenderOpts { edit, editing_text, ..Default::default() });
|
||
let plain = render(false, None);
|
||
assert_eq!(plain.pixels, render(true, None).pixels);
|
||
assert_eq!(plain.pixels, render(false, Some(ShapeId(901))).pixels, "export ignores editor markers");
|
||
assert_eq!(plain.pixels, render(true, Some(ShapeId(902))).pixels, "other shapes stay empty");
|
||
let editing = render(true, Some(ShapeId(901)));
|
||
let changed: Vec<_> = editing.pixels.as_chunks::<4>().0.iter().zip(plain.pixels.as_chunks::<4>().0).filter(|(a, b)| a != b).collect();
|
||
assert!(!changed.is_empty(), "an empty bullet must be visible");
|
||
assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179));
|
||
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
|
||
}
|