diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e5fff04..1164dcc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -435,7 +435,9 @@ pub fn shape_geometry(shape: &Shape, w: f64, h: f64) -> Geometry { let (pw, ph) = (if p.w > 0.0 { p.w } else { w.max(1e-9) }, if p.h > 0.0 { p.h } else { h.max(1e-9) }); let mut bp = parse_path(&p.d); bp.apply_affine(Affine::scale_non_uniform(w / pw, h / ph)); - out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: false }); + // PowerPoint fills each custom path's sub-paths alternately (even-odd): a sub-path + // inside another cuts a hole whichever way it winds, as in icons and outlined text. + out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: true }); } Geometry { paths: out, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: vec![] } } @@ -1614,6 +1616,9 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_wild; + #[cfg(test)] mod mt_tests { use super::*; diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs new file mode 100644 index 0000000..6da19b5 --- /dev/null +++ b/crates/render/src/tests_wild.rs @@ -0,0 +1,50 @@ +//! Render tests for shape, line, fill and effect cases checked against PowerPoint's own exports +//! of synthetic "wild" slides (observed black-box; see `plan/powerpoint/tools/gen_wild.py`). + +use super::*; +use deckcraft_color::Rgba; +use deckcraft_model::style::ColorRef; +use deckcraft_model::{CustomPath, Geom, Presentation, Shape, ShapeId, Xfrm}; + +fn deck_with(shapes: Vec) -> Presentation { + let mut p = Presentation::default(); + let s = std::sync::Arc::make_mut(&mut p.slides[0]); + s.shapes = shapes; + p +} + +fn render(shapes: Vec) -> Image { + render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +fn solid(c: Rgba) -> Option { + Some(Fill::solid(ColorRef::rgb(c))) +} + +fn no_line() -> Option { + Some(Line { fill: Some(Fill::None), ..Default::default() }) +} + +fn close(px: [u8; 4], c: Rgba, tol: i32) -> bool { + (px[0] as i32 - c.r as i32).abs() <= tol && (px[1] as i32 - c.g as i32).abs() <= tol && (px[2] as i32 - c.b as i32).abs() <= tol +} + +const WHITE: Rgba = Rgba::WHITE; +const RED: Rgba = Rgba::rgb(255, 0, 0); + +#[test] +fn custom_geometry_sub_paths_cut_holes_whichever_way_they_wind() { + // Outer square clockwise, inner square also clockwise: PowerPoint still shows a hole. + let d = "M 0 0 L 100 0 L 100 100 L 0 100 Z M 25 25 L 75 25 L 75 75 L 25 75 Z"; + let sh = Shape { + id: ShapeId(10), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 200.0)), + geom: Geom::Custom { paths: vec![CustomPath { w: 100.0, h: 100.0, d: d.into(), ..Default::default() }] }, + fill: solid(RED), + line: no_line(), + ..Default::default() + }; + let img = render(vec![sh]); + assert!(close(img.pixel(120, 120), RED, 2), "outer ring filled"); + assert!(close(img.pixel(200, 200), WHITE, 2), "inner square is a hole: {:?}", img.pixel(200, 200)); +}