diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 54eb4c8..acaff2b 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -134,11 +134,8 @@ pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm { /// Effective fill and the style colour (`phClr`) to resolve it with. pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option, Option) { let (lp, mp) = parents(ctx, shape); - for s in [Some(shape), lp, mp].into_iter().flatten() { - if let Some(f) = &s.fill { - return (Some(f.clone()), None); - } - if let Some(st) = &s.style { + let style_fill = |s: &Shape| { + s.style.as_ref().map(|st| { let (idx, col) = &st.fill_ref; let ph = ctx.color(col, None); let f = match *idx { @@ -146,7 +143,25 @@ pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option, Option) { i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(), i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(), }; - return (f, Some(ph)); + (f, Some(ph)) + }) + }; + for s in [Some(shape), lp, mp].into_iter().flatten() { + if let Some(f) = &s.fill { + // A gradient without stops (`a:gradFill` with no `a:gsLst`) takes them from the + // gradient its style refers to, keeping its own direction, as PowerPoint draws it. + if let Fill::Gradient(g) = f + && g.stops.is_empty() + && let Some((Some(Fill::Gradient(sg)), ph)) = style_fill(s) + { + let mut g = g.clone(); + g.stops = sg.stops; + return (Some(Fill::Gradient(g)), ph); + } + return (Some(f.clone()), None); + } + if let Some(r) = style_fill(s) { + return r; } } if shape.text_box { diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 2d028f8..4423ad1 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -62,6 +62,29 @@ fn shape_style_fill_resolves_through_theme() { assert!(ph.is_some()); } +#[test] +fn a_gradient_without_stops_takes_them_from_its_style() { + // `a:gradFill` with only a direction: PowerPoint fills it with the stops of the theme gradient + // the style's fillRef names, in the style's colour, along the shape's own direction. + let p = Presentation::default(); + let s = &p.slides[0]; + let ctx = Ctx::for_slide(&p, s).unwrap(); + let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; + let mut st = ShapeStyle::accent(SchemeSlot::Accent2); + st.fill_ref.0 = 3; + let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() }; + let (f, ph) = resolve::fill(&ctx, &sh); + let Some(Fill::Gradient(g)) = f else { panic!("expected a gradient") }; + let Some(Fill::Gradient(theme)) = p.masters[0].theme.format.fills.get(2).cloned() else { panic!("theme fill 3 is a gradient") }; + assert_eq!(g.stops, theme.stops); + assert_eq!(g.shape, own.shape, "the shape's own direction"); + assert_eq!(ph, Some(p.masters[0].theme.colors.get(SchemeSlot::Accent2))); + // A solid style fill has no stops to give: the gradient stays empty (draws nothing). + st.fill_ref.0 = 1; + let sh = Shape { style: Some(st), fill: Some(Fill::Gradient(own.clone())), ..Default::default() }; + assert_eq!(resolve::fill(&ctx, &sh).0, Some(Fill::Gradient(own))); +} + #[test] fn serde_roundtrip() { let p = Presentation::default(); diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 4c2e5d9..6761f0b 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -78,22 +78,22 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { Fill::Solid { color: c } => solid(w, c), Fill::Gradient(g) => { w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" })); - w.open0("a:gsLst"); + // No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill. let mut stops = g.stops.clone(); - if stops.is_empty() { - stops.push(deckcraft_model::style::GradientStop { pos: 0.0, color: ColorRef::rgb(deckcraft_color::Rgba::WHITE) }); - } if stops.len() == 1 { let mut s = stops[0].clone(); s.pos = 1.0; stops.push(s); } - for s in &stops { - w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0)))); - color(w, &s.color); - w.close("a:gs"); + if !stops.is_empty() { + w.open0("a:gsLst"); + for s in &stops { + w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0)))); + color(w, &s.color); + w.close("a:gs"); + } + w.close("a:gsLst"); } - w.close("a:gsLst"); match &g.shape { GradientShape::Linear { angle, scaled } => { w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })) diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index cd096de..956af5f 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -79,3 +79,26 @@ fn regex_free_strip(s: &str, open: &str, close: &str) -> String { let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() }; format!("{}{}", &s[..i], &s[j..]) } + +#[test] +fn a_gradient_without_stops_is_saved_without_stops() { + // `a:gradFill` with no `a:gsLst` takes its stops from the style's fill in PowerPoint; saving + // must not turn it into a gradient of its own (it used to become solid white). + use deckcraft_model::style::{Gradient, GradientShape}; + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Styled".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + fill: Some(Fill::Gradient(own.clone())), + style: Some(deckcraft_model::ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape"); + assert_eq!(s.fill, Some(Fill::Gradient(own))); +}