diff --git a/crates/engine/src/cmd/shape/appearance.rs b/crates/engine/src/cmd/shape/appearance.rs index 460565f7..09a0e236 100644 --- a/crates/engine/src/cmd/shape/appearance.rs +++ b/crates/engine/src/cmd/shape/appearance.rs @@ -108,7 +108,7 @@ pub(crate) fn gradient_param(v: &Value) -> Option { scaled: false, }, }; - Some(Gradient { tile: [0.0; 4], 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), flip: None }) } /// Fill and outline apply to a group's visible members as well as its container. Other edits diff --git a/crates/engine/src/cmd/smartart/paint.rs b/crates/engine/src/cmd/smartart/paint.rs index fc75a1f9..e49efd90 100644 --- a/crates/engine/src/cmd/smartart/paint.rs +++ b/crates/engine/src/cmd/smartart/paint.rs @@ -99,6 +99,8 @@ pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { shape: GradientShape::Linear { angle: 90.0, scaled: false }, rotate_with_shape: true, tile: [0.0; 4], + + flip: None, })); sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)); sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() }); diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 83c494bd..a653da93 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -72,6 +72,36 @@ pub struct GradientStop { pub color: ColorRef, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GradientFlip { + None, + X, + Y, + Xy, +} + +impl GradientFlip { + pub fn xml(self) -> &'static str { + match self { + GradientFlip::None => "none", + GradientFlip::X => "x", + GradientFlip::Y => "y", + GradientFlip::Xy => "xy", + } + } + + pub fn from_xml(value: &str) -> Option { + match value { + "none" => Some(GradientFlip::None), + "x" => Some(GradientFlip::X), + "y" => Some(GradientFlip::Y), + "xy" => Some(GradientFlip::Xy), + _ => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum GradientShape { @@ -90,6 +120,8 @@ pub enum GradientShape { pub struct Gradient { pub stops: Vec, pub shape: GradientShape, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flip: Option, #[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 diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 38d815ef..1fd29aef 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -73,8 +73,13 @@ 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 { tile: [0.0; 4], 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, + flip: None, + }; 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 870a8bb5..27fe93e3 100644 --- a/crates/model/src/theme.rs +++ b/crates/model/src/theme.rs @@ -115,6 +115,8 @@ pub fn default_format() -> FormatScheme { ], shape: GradientShape::Linear { angle: 90.0, scaled: false }, rotate_with_shape: true, + + flip: None, }) }; FormatScheme { diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 767b7dc0..626f5877 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -3,8 +3,8 @@ use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_geom::{Xfrm, emu_to_pt}; use deckcraft_model::style::{ - ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineCap, LineEnd, LineJoin, PatternFill, - PictureFill, PictureMode, Reflection, Shadow, + ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineCap, LineEnd, LineJoin, + PatternFill, PictureFill, PictureMode, Reflection, Shadow, }; use deckcraft_model::text::{ Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, LevelStyle, ListStyle, ParaProps, Paragraph, Run, RunKind, RunProps, Spacing, @@ -122,7 +122,13 @@ pub fn gradient(f: &El) -> Gradient { GradientShape::Linear { angle: 90.0, scaled: false } }; 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) } + Gradient { + tile: tile.unwrap_or_default(), + stops, + shape, + flip: f.attr("flip").and_then(GradientFlip::from_xml), + 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 518f8c4a..aeae9801 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -1,7 +1,9 @@ //! DrawingML writing: colours, fills, lines, effects, transforms and text. use deckcraft_geom::{Xfrm, pt_to_emu}; -use deckcraft_model::style::{ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientShape, Line, LineCap, LineJoin, PictureFill, PictureMode}; +use deckcraft_model::style::{ + ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientFlip, GradientShape, Line, LineCap, LineJoin, PictureFill, PictureMode, +}; use deckcraft_model::text::{ Action, AutoFit, BodyProps, Bullet, Caps, Hyperlink, ListStyle, ParaProps, Paragraph, RunKind, RunProps, Spacing, Strike, TextBody, }; @@ -279,7 +281,7 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) { } fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) { - w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" })); + w.open("a:gradFill", A::new().o("flip", g.flip.map(GradientFlip::xml)).a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" })); // No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill. let mut stops = g.stops.clone(); if stops.len() == 1 { diff --git a/crates/pptx/tests/it/corpus_style.rs b/crates/pptx/tests/it/corpus_style.rs index 6eac7bdd..42289760 100644 --- a/crates/pptx/tests/it/corpus_style.rs +++ b/crates/pptx/tests/it/corpus_style.rs @@ -46,6 +46,8 @@ fn gradient(angle: f64) -> Fill { shape: GradientShape::Linear { angle, scaled: true }, rotate_with_shape: true, tile: [0.0; 4], + + flip: None, }) } @@ -116,6 +118,8 @@ fn style_deck() -> Presentation { shape: GradientShape::Path { path: "circle".into(), focus: [0.2, 0.2, 0.2, 0.2] }, rotate_with_shape: false, tile: [0.0; 4], + + flip: None, })); let mut patt = shape(&mut p, "Pattern", 200.0, 260.0, 120.0, 80.0); diff --git a/crates/pptx/tests/it/fills.rs b/crates/pptx/tests/it/fills.rs index 98069d11..51bd51de 100644 --- a/crates/pptx/tests/it/fills.rs +++ b/crates/pptx/tests/it/fills.rs @@ -88,7 +88,8 @@ fn a_gradient_without_stops_is_saved_without_stops() { let mut p = defaults::new_presentation(None); let id = ShapeId(p.alloc_id().expect("ids")); let first = p.slides[0].id; - let own = Gradient { tile: [0.0; 4], 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, flip: None }; p.slide_mut(first).expect("slide").shapes.push(Shape { id, name: "Styled".into(), @@ -119,6 +120,8 @@ fn a_fill_overlay_round_trips() { ], shape: GradientShape::Linear { angle: 4.5, scaled: true }, rotate_with_shape: true, + + flip: None, }), }; p.slide_mut(first).expect("slide").shapes.push(Shape { @@ -148,6 +151,8 @@ fn a_gradient_tile_rect_is_read_and_round_trips() { 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], + + flip: None, }; let mut p = defaults::new_presentation(None); let id = ShapeId(p.alloc_id().expect("ids")); diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 370cc9a9..98516310 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -7,8 +7,8 @@ use deckcraft_geom::Xfrm; use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition}; use deckcraft_model::chart::{Chart, ChartType}; use deckcraft_model::style::{ - ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill, PictureMode, - Shadow, + ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill, + PictureMode, Shadow, }; use deckcraft_model::table::Table; use deckcraft_model::text::{ @@ -115,6 +115,8 @@ pub fn rich_deck() -> Presentation { ], shape: GradientShape::Linear { angle: 45.0, scaled: false }, rotate_with_shape: true, + + flip: Some(GradientFlip::Xy), })); a.line = Some(Line { width: Some(3.0), dash: Some(Dash::Preset { name: "dash".into() }), ..Line::solid(ColorRef::rgb(Rgba::rgb(0, 0, 0)), 3.0) }); a.effects = Some(Effects { @@ -488,6 +490,14 @@ fn shapes_fills_lines_effects_round_trip() { assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id); } +#[test] +fn gradient_flip_mode_survives_two_pptx_round_trips() { + let p = rich_deck(); + let q = round(&round(&p)); + let gradient = by_name(&q.slides[1], "Gradient Star").fill.as_ref(); + assert!(matches!(gradient, Some(Fill::Gradient(g)) if g.flip == Some(GradientFlip::Xy))); +} + #[test] fn pictures_media_and_ink_round_trip() { let p = rich_deck(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index db8644ab..ad6b9424 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -978,6 +978,8 @@ fn a_linear_gradient_ramps_like_powerpoint() { 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, + + flip: None, })), line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), ..Default::default() @@ -1003,6 +1005,8 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() { 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, + + flip: None, })), line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), ..Default::default() @@ -1044,6 +1048,8 @@ fn a_shape_filled_with_the_background_shows_the_slide_background() { 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, + + flip: None, }), }); let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); @@ -1145,6 +1151,8 @@ fn a_text_gradient_starts_over_on_every_line() { stops: vec![stop(0.0, 0), stop(1.0, 255)], shape: GradientShape::Linear { angle: 90.0, scaled: false }, rotate_with_shape: true, + + flip: None, })); } sh.text = Some(t); @@ -1306,6 +1314,8 @@ fn rect_path_gradients_draw_x_shaped_contours() { 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, + + flip: None, })), line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), ..Default::default() diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index fb561346..c73987af 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -186,6 +186,8 @@ fn radial(focus: [f64; 4]) -> Shape { stops, shape: GradientShape::Path { path: "circle".into(), focus }, rotate_with_shape: true, + + flip: None, })), line: no_line(), ..Default::default() @@ -310,8 +312,13 @@ 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 { tile: [0.0; 4], 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, + flip: None, + }); let child = |id: u32, x: f64| Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)), @@ -372,7 +379,13 @@ 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 g = Gradient { + stops, + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0, 0.0, 0.5, 0.0], + flip: None, + }; let sh = Shape { id: ShapeId(20), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)), @@ -502,6 +515,8 @@ fn gradient_lines_and_their_heads_take_the_gradient() { shape: GradientShape::Linear { angle, scaled: false }, rotate_with_shape: true, tile: [0.0; 4], + + flip: None, }) }; // Left to right along a 20 pt line: red at its start, blue at its end, and the arrowhead at @@ -656,6 +671,8 @@ fn a_gradient_not_rotating_with_its_shape_keeps_to_the_slide() { shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape, tile: [0.0; 4], + + flip: None, }; // 200 × 100 turned a quarter: it covers 150..250 across and 50..250 down. let sh = Shape { diff --git a/crates/ui-egui/src/panels/fillui.rs b/crates/ui-egui/src/panels/fillui.rs index 5150b8f0..06b1ac93 100644 --- a/crates/ui-egui/src/panels/fillui.rs +++ b/crates/ui-egui/src/panels/fillui.rs @@ -21,6 +21,8 @@ pub fn default_gradient(base: ColorRef, angle: f64) -> Gradient { stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }], shape: GradientShape::Linear { angle, scaled: false }, rotate_with_shape: true, + + flip: None, } } @@ -142,6 +144,8 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> 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, + + flip: None, }, Gradient { stops: vec![GradientStop { pos: 0.0, color: base.clone() }, GradientStop { pos: 1.0, color: with_alpha(base, 0.0) }],