Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 c1a9915761 Fills: a gradient without stops takes them from its style, and keeps none when saved
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:01:18 -04:00
Brandon ThomasandClaude Opus 5.5 f94f3c12d1 Canvas: objects off the slide show and can be grabbed around it, as in PowerPoint (#270) (#329)
PowerPoint's Normal view draws shapes, pictures and text that sit partly
or wholly off the slide on the area around it, at full strength, with
their effects, master and layout graphics included; the slide show,
exports and thumbnails clip to the slide. DeckCraft clipped everything to
the slide in the editor too.

- render: `slide_extent`/`layout_extent` estimate how far a slide's
  drawing reaches (boxes, outlines, shadow, glow, reflection);
  `render_slide_drawn` also reports what a render actually drew (text
  spilling out of its box, table rows that grew); `pasteboard_region`
  picks the pixels the editor renders: the slide plus what reaches off
  it and is in view, rounded to 256 px tiles so scrolling rarely
  re-renders. A slide with nothing off it renders exactly as before.
- ui-egui: the canvas texture covers that region (slide and master
  views); it re-renders only when the slide, zoom or the region changes,
  and a second time only if text drew beyond the estimate.
- Hit testing, selection, marquee and moves already worked in slide
  space; tests now cover them off the slide.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 21:47:05 -04:00
4 changed files with 76 additions and 15 deletions
+21 -6
View File
@@ -134,11 +134,8 @@ pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm {
/// Effective fill and the style colour (`phClr`) to resolve it with. /// Effective fill and the style colour (`phClr`) to resolve it with.
pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) { pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
let (lp, mp) = parents(ctx, shape); let (lp, mp) = parents(ctx, shape);
for s in [Some(shape), lp, mp].into_iter().flatten() { let style_fill = |s: &Shape| {
if let Some(f) = &s.fill { s.style.as_ref().map(|st| {
return (Some(f.clone()), None);
}
if let Some(st) = &s.style {
let (idx, col) = &st.fill_ref; let (idx, col) = &st.fill_ref;
let ph = ctx.color(col, None); let ph = ctx.color(col, None);
let f = match *idx { let f = match *idx {
@@ -146,7 +143,25 @@ pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(), 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(), 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 { if shape.text_box {
+23
View File
@@ -62,6 +62,29 @@ fn shape_style_fill_resolves_through_theme() {
assert!(ph.is_some()); 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] #[test]
fn serde_roundtrip() { fn serde_roundtrip() {
let p = Presentation::default(); let p = Presentation::default();
+9 -9
View File
@@ -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::Solid { color: c } => solid(w, c),
Fill::Gradient(g) => { Fill::Gradient(g) => {
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" })); 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(); 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 { if stops.len() == 1 {
let mut s = stops[0].clone(); let mut s = stops[0].clone();
s.pos = 1.0; s.pos = 1.0;
stops.push(s); stops.push(s);
} }
for s in &stops { if !stops.is_empty() {
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0)))); w.open0("a:gsLst");
color(w, &s.color); for s in &stops {
w.close("a:gs"); 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 { match &g.shape {
GradientShape::Linear { angle, scaled } => { GradientShape::Linear { angle, scaled } => {
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })) w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
+23
View File
@@ -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() }; let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
format!("{}{}", &s[..i], &s[j..]) 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)));
}