Render: custom geometry sub-paths fill even-odd, so inner sub-paths cut holes

A freeform whose path holds a sub-path inside another (an icon, outlined
text, a frame drawn as two rectangles) showed the inner one filled when
both wound the same way: DeckCraft filled custom paths non-zero.
PowerPoint fills every custom path alternately (even-odd), so the inner
sub-path is a hole whichever way it winds.

Wild synthetic set: holes slide 7.60 -> 2.84 mean diff.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 04:35:22 -04:00
parent 525ec7b1a9
commit 39adfcd62a
2 changed files with 56 additions and 1 deletions

View File

@ -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::*;

View File

@ -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<Shape>) -> Presentation {
let mut p = Presentation::default();
let s = std::sync::Arc::make_mut(&mut p.slides[0]);
s.shapes = shapes;
p
}
fn render(shapes: Vec<Shape>) -> Image {
render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
fn solid(c: Rgba) -> Option<Fill> {
Some(Fill::solid(ColorRef::rgb(c)))
}
fn no_line() -> Option<Line> {
Some(Line { fill: Some(Fill::None), ..Default::default() })
}
fn close(px: [u8; 4], c: Rgba, tol: i32) -> bool {
(px[0] as i32 - c.r as i32).abs() <= tol && (px[1] as i32 - c.g as i32).abs() <= tol && (px[2] as i32 - c.b as i32).abs() <= tol
}
const WHITE: Rgba = Rgba::WHITE;
const RED: Rgba = Rgba::rgb(255, 0, 0);
#[test]
fn custom_geometry_sub_paths_cut_holes_whichever_way_they_wind() {
// Outer square clockwise, inner square also clockwise: PowerPoint still shows a hole.
let d = "M 0 0 L 100 0 L 100 100 L 0 100 Z M 25 25 L 75 25 L 75 75 L 25 75 Z";
let sh = Shape {
id: ShapeId(10),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 200.0)),
geom: Geom::Custom { paths: vec![CustomPath { w: 100.0, h: 100.0, d: d.into(), ..Default::default() }] },
fill: solid(RED),
line: no_line(),
..Default::default()
};
let img = render(vec![sh]);
assert!(close(img.pixel(120, 120), RED, 2), "outer ring filled");
assert!(close(img.pixel(200, 200), WHITE, 2), "inner square is a hole: {:?}", img.pixel(200, 200));
}