diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 22cb385..b4c6aa4 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -261,7 +261,7 @@ pub(crate) fn gradient_param(v: &Value) -> Option { scaled: false, }, }; - Some(Gradient { stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) }) + Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) }) } fn fill(s: &mut Session, p: &Value) -> Result { diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index bb25397..c7cd148 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -92,6 +92,15 @@ pub struct Gradient { pub shape: GradientShape, #[serde(default = "crate::yes")] pub rotate_with_shape: bool, + /// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box + /// (fractions; negative reaches outside it). PowerPoint's "from corner" radial presets use it + /// to centre the gradient on the corner. + #[serde(default, skip_serializing_if = "is_zero4")] + pub tile: [f64; 4], +} + +fn is_zero4(v: &[f64; 4]) -> bool { + v.iter().all(|x| *x == 0.0) } /// How a picture fills its box: stretched into a (fractional) inset rect, or tiled. diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 61a8922..2c3b4ce 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -69,7 +69,8 @@ fn a_gradient_without_stops_takes_them_from_its_style() { 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 own = + style::Gradient { tile: [0.0; 4], 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() }; diff --git a/crates/model/src/theme.rs b/crates/model/src/theme.rs index 55b416b..582f072 100644 --- a/crates/model/src/theme.rs +++ b/crates/model/src/theme.rs @@ -108,6 +108,7 @@ fn ph() -> ColorRef { pub fn default_format() -> FormatScheme { let grad = |a: i32, b: i32| { Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![ GradientStop { pos: 0.0, color: ph().with(ColorTransform::LumMod(a)).with(ColorTransform::SatMod(103000)) }, GradientStop { pos: 1.0, color: ph().with(ColorTransform::LumMod(b)) }, diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index ab67a25..004cd4b 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -116,7 +116,8 @@ pub fn gradient(f: &El) -> Gradient { } else { GradientShape::Linear { angle: 90.0, scaled: false } }; - Gradient { stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) } + let tile = f.child("tileRect").map(|r| [pct(r, "l"), pct(r, "t"), pct(r, "r"), pct(r, "b")].map(|v| v.unwrap_or(0.0).clamp(-10.0, 10.0))); + Gradient { tile: tile.unwrap_or_default(), stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) } } /// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media. diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 7bae42d..c1168ca 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -272,6 +272,10 @@ fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) { w.close("a:path"); } } + if g.tile.iter().any(|v| *v != 0.0) { + let [l, t, r, b] = g.tile; + w.empty("a:tileRect", A::new().a("l", pct(l)).a("t", pct(t)).a("r", pct(r)).a("b", pct(b))); + } w.close("a:gradFill"); } diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index b129e64..8620b76 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -88,7 +88,7 @@ fn a_gradient_without_stops_is_saved_without_stops() { 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 }; + let own = Gradient { tile: [0.0; 4], 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(), @@ -112,6 +112,7 @@ fn a_fill_overlay_round_trips() { let overlay = FillOverlay { blend: "mult".into(), fill: Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![ GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) }, GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) }, @@ -133,3 +134,39 @@ fn a_fill_overlay_round_trips() { let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape"); assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay)); } + +#[test] +fn a_gradient_tile_rect_is_read_and_round_trips() { + use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; + // PowerPoint's "from top-left corner" radial: focus on the corner, tile reaching a box up and + // to the left so it centres on that corner. + let g = Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 128, 255)) }, + GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE) }, + ], + shape: GradientShape::Path { path: "circle".into(), focus: [0.0, 0.0, 1.0, 1.0] }, + rotate_with_shape: true, + tile: [-1.0, -1.0, 0.0, 0.0], + }; + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Corner".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + fill: Some(Fill::Gradient(g.clone())), + ..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 == "Corner").expect("shape"); + assert_eq!(s.fill, Some(Fill::Gradient(g))); + // As written by PowerPoint (attributes it leaves out are 0). + let edited = + edit_slides(&bytes, |x| x.replace("", "")); + let q = deckcraft_pptx::import(&edited).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Corner").expect("shape"); + assert!(matches!(&s.fill, Some(Fill::Gradient(g)) if g.tile == [-1.0, -1.0, 0.0, 0.0]), "{:?}", s.fill); +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index eb194b8..ed5cb42 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -105,6 +105,7 @@ pub fn rich_deck() -> Presentation { let mut a = shape(&mut p, "Gradient Star", 100.0, 120.0, 150.0, 150.0); a.geom = Geom::Preset { name: "star5".into(), adj: vec![20000.0] }; a.fill = Some(Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![ GradientStop { pos: 0.0, color: ColorRef::scheme(SchemeSlot::Accent1) }, GradientStop { pos: 1.0, color: ColorRef::scheme(SchemeSlot::Accent2).with(ColorTransform::LumMod(75000)) }, diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 13b4f4f..89b1342 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -35,6 +35,10 @@ pub fn fill_path( if g.stops.is_empty() { return; } + // A linear gradient spans its tile rectangle (the shape's box unless `tileRect` moves + // its edges); a radial one ends on the circle around it, from a focus placed in the + // shape's own box (PowerPoint's "from corner" presets centre the tile on the corner). + let tile = tile_box(bounds, g.tile); let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect(); ctx.set_paint(gradient(&g.shape, stops, bounds, alpha)); @@ -55,12 +59,12 @@ pub fn fill_path( // Angle measured clockwise from left→right; span the bounds along that direction. let a = angle.to_radians(); let (dx, dy) = (a.cos(), a.sin()); - let c = bounds.center(); - let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0; + let c = tile.center(); + let half = (tile.width() * dx.abs() + tile.height() * dy.abs()) / 2.0; peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) .with_stops(stops.as_slice()) } - GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()), + GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, tile).with_stops(stops.as_slice()), }; ctx.set_paint(grad); ctx.fill_path(path); @@ -88,6 +92,22 @@ pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option) -> std::b std::borrow::Cow::Owned(out) } +/// `bounds` with its edges moved in by `tile` (left, top, right, bottom insets as fractions of its +/// size; negative moves them out), as `a:tileRect` and `a:fillRect` lay out a fill. +pub fn tile_box(bounds: Rect, tile: [f64; 4]) -> Rect { + if tile.iter().all(|v| *v == 0.0) || tile.iter().any(|v| !v.is_finite()) { + return bounds; + } + let (w, h) = (bounds.width(), bounds.height()); + let [l, t, r, b] = tile; + let x0 = bounds.x0 + l * w; + let x1 = bounds.x1 - r * w; + let y0 = bounds.y0 + t * h; + let y1 = bounds.y1 - b * h; + // An inverted tile (insets past each other) lays out nothing sensible: keep the box. + if x1 - x0 <= 1e-9 || y1 - y0 <= 1e-9 { bounds } else { Rect::new(x0, y0, x1, y1) } +} + /// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded /// by `alpha`, ramping between stops as PowerPoint does (see `ramp`). pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient { @@ -107,7 +127,7 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) .with_stops(stops.as_slice()) } - GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()), + GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, bounds).with_stops(stops.as_slice()), } } @@ -117,11 +137,14 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect /// centre, half its diagonal), with circles in between moving from one to the other. A focus off /// the centre so bunches the colours on its near side; a gradient "from a corner" reaches its /// last colour at that corner too, not only at the far one. -pub fn circle_gradient(focus: [f64; 4], bounds: Rect) -> peniko::Gradient { +/// +/// With a tile rectangle (`tile`, otherwise `bounds` itself) the end circle is the one around +/// the tile, while the focus stays placed in `bounds`. +pub fn circle_gradient(focus: [f64; 4], bounds: Rect, tile: Rect) -> peniko::Gradient { let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; - let c = bounds.center(); - let r = (bounds.width().hypot(bounds.height()) / 2.0).max(1e-6); + let c = tile.center(); + let r = (tile.width().hypot(tile.height()) / 2.0).max(1e-6); let f = Point::new(fx, fy); if !(f.is_finite() && c.is_finite() && r.is_finite()) { return peniko::Gradient::new_radial(c, r as f32); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e5b365f..0de3d69 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -565,6 +565,7 @@ fn a_linear_gradient_ramps_like_powerpoint() { 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, @@ -589,6 +590,7 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() { 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, @@ -629,6 +631,7 @@ fn a_shape_filled_with_the_background_shows_the_slide_background() { 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, @@ -702,6 +705,7 @@ fn a_text_gradient_starts_over_on_every_line() { 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, @@ -862,6 +866,7 @@ fn rect_path_gradients_draw_x_shaped_contours() { 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, diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 85ed586..064dce0 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -168,7 +168,12 @@ fn radial(focus: [f64; 4]) -> Shape { Shape { id: ShapeId(13), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), - fill: Some(Fill::Gradient(Gradient { stops, shape: GradientShape::Path { path: "circle".into(), focus }, rotate_with_shape: true })), + fill: Some(Fill::Gradient(Gradient { + tile: [0.0; 4], + stops, + shape: GradientShape::Path { path: "circle".into(), focus }, + rotate_with_shape: true, + })), line: no_line(), ..Default::default() } @@ -292,7 +297,8 @@ fn a_pictures_border_lies_outside_the_picture() { fn children_filled_with_the_group_fill_show_one_fill_across_the_group() { use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }]; - let grad = Fill::Gradient(Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true }); + let grad = + Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true }); let child = |id: u32, x: f64| Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)), @@ -331,3 +337,37 @@ fn open_freeforms_take_arrowheads_even_when_filled() { // (295.7, 187.3), 3.5 pt off the line's axis: outside the 4 pt line and outside the fill. assert!(close(img.pixel(294, 189), RED, 60), "arrowhead drawn {:?}", img.pixel(294, 189)); } + +#[test] +fn a_corner_radial_centres_on_the_corner_through_its_tile() { + // fillToRect on the top-left corner, tileRect reaching a whole box up and left: the circle is + // centred on the corner and reaches the far corner (as PowerPoint's "from corner" presets). + let mut sh = radial([0.0, 0.0, 1.0, 1.0]); + if let Some(Fill::Gradient(g)) = &mut sh.fill { + g.tile = [-1.0, -1.0, 0.0, 0.0]; + } + let img = render(vec![sh]); + assert!(img.pixel(102, 102)[0] > 240, "white at the corner {:?}", img.pixel(102, 102)); + let (top_right, bottom_left) = (img.pixel(498, 101)[0], img.pixel(101, 298)[0]); + assert!(top_right < bottom_left, "farther is darker {top_right} {bottom_left}"); + assert!(bottom_left > 120, "the near edge stays light {bottom_left}"); + assert!(img.pixel(497, 297)[0] < 40, "dark at the far corner {:?}", img.pixel(497, 297)); +} + +#[test] +fn a_linear_gradient_spans_its_tile_rect() { + use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; + // Tile rect the left half of the box: the ramp ends half way and stays black after. + let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }]; + let g = Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, tile: [0.0, 0.0, 0.5, 0.0] }; + let sh = Shape { + id: ShapeId(20), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)), + fill: Some(Fill::Gradient(g)), + line: no_line(), + ..Default::default() + }; + let img = render(vec![sh]); + assert!(img.pixel(102, 150)[0] > 240); + assert!(img.pixel(305, 150)[0] < 15 && img.pixel(495, 150)[0] < 15, "{:?}", img.pixel(305, 150)); +} diff --git a/crates/ui-egui/src/fillui.rs b/crates/ui-egui/src/fillui.rs index 7f0f7d1..584d309 100644 --- a/crates/ui-egui/src/fillui.rs +++ b/crates/ui-egui/src/fillui.rs @@ -16,6 +16,7 @@ pub fn default_gradient(base: ColorRef, angle: f64) -> Gradient { let mut dark = base.clone(); dark.mods.push(ColorTransform::LumMod(60_000)); Gradient { + tile: [0.0; 4], stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }], shape: GradientShape::Linear { angle, scaled: false }, rotate_with_shape: true, @@ -136,6 +137,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> ..default_gradient(base.clone(), 45.0) }, Gradient { + tile: [0.0; 4], stops: vec![GradientStop { pos: 0.0, color: light }, GradientStop { pos: 1.0, color: dark }], shape: GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] }, rotate_with_shape: true,