From ad97533aec09aa18737ba051970e436b7d2059f6 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:57:14 -0500 Subject: [PATCH 1/4] Preserve gradient flip mode in PPTX --- crates/engine/src/cmd/shape/appearance.rs | 2 +- crates/engine/src/cmd/smartart/paint.rs | 2 ++ crates/model/src/style.rs | 32 +++++++++++++++++++++++ crates/model/src/tests.rs | 9 +++++-- crates/model/src/theme.rs | 2 ++ crates/pptx/src/read/dml.rs | 12 ++++++--- crates/pptx/src/write/dml.rs | 6 +++-- crates/pptx/tests/it/corpus_style.rs | 4 +++ crates/pptx/tests/it/fills.rs | 7 ++++- crates/pptx/tests/it/roundtrip.rs | 14 ++++++++-- crates/render/src/tests.rs | 10 +++++++ crates/render/src/tests_wild.rs | 23 +++++++++++++--- crates/ui-egui/src/panels/fillui.rs | 4 +++ 13 files changed, 113 insertions(+), 14 deletions(-) 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) }], From 72c8621358c40a8b2443c939a78207db74c35592 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:17:32 -0500 Subject: [PATCH 2/4] Keep gradient flip when editing a fill --- crates/engine/src/cmd/shape/appearance.rs | 19 ++++++++++++++++- crates/ui-egui/src/panels/fillui.rs | 26 ++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/shape/appearance.rs b/crates/engine/src/cmd/shape/appearance.rs index 09a0e236..24bfbabf 100644 --- a/crates/engine/src/cmd/shape/appearance.rs +++ b/crates/engine/src/cmd/shape/appearance.rs @@ -108,7 +108,13 @@ 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), flip: None }) + Some(Gradient { + tile: [0.0; 4], + stops, + shape, + rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true), + flip: v.get("flip").and_then(Value::as_str).and_then(deckcraft_model::style::GradientFlip::from_xml), + }) } /// Fill and outline apply to a group's visible members as well as its container. Other edits @@ -555,3 +561,14 @@ pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result { Ok(()) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gradient_parameters_keep_flip_mode() { + let gradient = gradient_param(&json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "flip": "xy"})).expect("gradient"); + assert_eq!(gradient.flip, Some(deckcraft_model::style::GradientFlip::Xy)); + } +} diff --git a/crates/ui-egui/src/panels/fillui.rs b/crates/ui-egui/src/panels/fillui.rs index 06b1ac93..abee9bea 100644 --- a/crates/ui-egui/src/panels/fillui.rs +++ b/crates/ui-egui/src/panels/fillui.rs @@ -4,7 +4,7 @@ use deckcraft_color::{ColorScheme, ColorTransform, SchemeSlot}; use deckcraft_model::ColorRef; -use deckcraft_model::style::{Gradient, GradientShape, GradientStop, PatternFill}; +use deckcraft_model::style::{Gradient, GradientFlip, GradientShape, GradientStop, PatternFill}; use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -29,19 +29,39 @@ pub fn default_gradient(base: ColorRef, angle: f64) -> Gradient { /// The command parameter for `g` (`shape.fill {gradient}` / `design.background {gradient}`). pub fn gradient_json(g: &Gradient) -> Value { let stops: Vec = g.stops.iter().map(|s| json!([s.pos, cref_param(&s.color)])).collect(); + let flip = g.flip.map(GradientFlip::xml); match &g.shape { - GradientShape::Linear { angle, .. } => json!({"stops": stops, "kind": "linear", "angle": angle, "rotateWithShape": g.rotate_with_shape}), + GradientShape::Linear { angle, .. } => { + json!({"stops": stops, "kind": "linear", "angle": angle, "rotateWithShape": g.rotate_with_shape, "flip": flip}) + } GradientShape::Path { path, focus } => { let kind = match path.as_str() { "rect" => "rectangular", "shape" => "path", _ => "radial", }; - json!({"stops": stops, "kind": kind, "focus": focus, "rotateWithShape": g.rotate_with_shape}) + json!({"stops": stops, "kind": kind, "focus": focus, "rotateWithShape": g.rotate_with_shape, "flip": flip}) } } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gradient_json_retains_flip_mode() { + let gradient = Gradient { + stops: vec![], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + flip: Some(GradientFlip::Xy), + rotate_with_shape: true, + tile: [0.0; 4], + }; + assert_eq!(gradient_json(&gradient).get("flip"), Some(&json!("xy"))); + } +} + fn alpha_of(c: &ColorRef) -> f64 { c.mods.iter().find_map(|m| if let ColorTransform::Alpha(a) = m { Some(*a as f64 / 100_000.0) } else { None }).unwrap_or(1.0) } From 5a235590ddb1c9196e5e1715826d0bc8f8532d84 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:26:55 -0500 Subject: [PATCH 3/4] Move gradient editor test module to file end --- crates/ui-egui/src/panels/fillui.rs | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/panels/fillui.rs b/crates/ui-egui/src/panels/fillui.rs index abee9bea..3e247621 100644 --- a/crates/ui-egui/src/panels/fillui.rs +++ b/crates/ui-egui/src/panels/fillui.rs @@ -45,23 +45,6 @@ pub fn gradient_json(g: &Gradient) -> Value { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn gradient_json_retains_flip_mode() { - let gradient = Gradient { - stops: vec![], - shape: GradientShape::Linear { angle: 90.0, scaled: false }, - flip: Some(GradientFlip::Xy), - rotate_with_shape: true, - tile: [0.0; 4], - }; - assert_eq!(gradient_json(&gradient).get("flip"), Some(&json!("xy"))); - } -} - fn alpha_of(c: &ColorRef) -> f64 { c.mods.iter().find_map(|m| if let ColorTransform::Alpha(a) = m { Some(*a as f64 / 100_000.0) } else { None }).unwrap_or(1.0) } @@ -532,3 +515,20 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option } out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gradient_json_retains_flip_mode() { + let gradient = Gradient { + stops: vec![], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + flip: Some(GradientFlip::Xy), + rotate_with_shape: true, + tile: [0.0; 4], + }; + assert_eq!(gradient_json(&gradient).get("flip"), Some(&json!("xy"))); + } +} From 04b0958bee296fa9ba224170a08bc6c533696ffe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 00:54:16 -0400 Subject: [PATCH 4/4] Gradient flip: drop the blank lines before the new flip fields Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/smartart/paint.rs | 1 - crates/model/src/theme.rs | 1 - crates/pptx/tests/it/corpus_style.rs | 2 -- crates/pptx/tests/it/fills.rs | 2 -- crates/render/src/tests.rs | 5 ----- crates/render/src/tests_wild.rs | 3 --- crates/ui-egui/src/panels/fillui.rs | 2 -- 7 files changed, 16 deletions(-) diff --git a/crates/engine/src/cmd/smartart/paint.rs b/crates/engine/src/cmd/smartart/paint.rs index e49efd90..88192cad 100644 --- a/crates/engine/src/cmd/smartart/paint.rs +++ b/crates/engine/src/cmd/smartart/paint.rs @@ -99,7 +99,6 @@ 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)); diff --git a/crates/model/src/theme.rs b/crates/model/src/theme.rs index 27fe93e3..5087f3ea 100644 --- a/crates/model/src/theme.rs +++ b/crates/model/src/theme.rs @@ -115,7 +115,6 @@ pub fn default_format() -> FormatScheme { ], shape: GradientShape::Linear { angle: 90.0, scaled: false }, rotate_with_shape: true, - flip: None, }) }; diff --git a/crates/pptx/tests/it/corpus_style.rs b/crates/pptx/tests/it/corpus_style.rs index 42289760..bc3ea2fa 100644 --- a/crates/pptx/tests/it/corpus_style.rs +++ b/crates/pptx/tests/it/corpus_style.rs @@ -46,7 +46,6 @@ fn gradient(angle: f64) -> Fill { shape: GradientShape::Linear { angle, scaled: true }, rotate_with_shape: true, tile: [0.0; 4], - flip: None, }) } @@ -118,7 +117,6 @@ 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, })); diff --git a/crates/pptx/tests/it/fills.rs b/crates/pptx/tests/it/fills.rs index 51bd51de..c1b7e9f3 100644 --- a/crates/pptx/tests/it/fills.rs +++ b/crates/pptx/tests/it/fills.rs @@ -120,7 +120,6 @@ fn a_fill_overlay_round_trips() { ], shape: GradientShape::Linear { angle: 4.5, scaled: true }, rotate_with_shape: true, - flip: None, }), }; @@ -151,7 +150,6 @@ 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); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ad6b9424..f01ee1cf 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -978,7 +978,6 @@ 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() }), @@ -1005,7 +1004,6 @@ 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() }), @@ -1048,7 +1046,6 @@ 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, }), }); @@ -1151,7 +1148,6 @@ 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, })); } @@ -1314,7 +1310,6 @@ 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() }), diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index c73987af..ae78f64b 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -186,7 +186,6 @@ fn radial(focus: [f64; 4]) -> Shape { stops, shape: GradientShape::Path { path: "circle".into(), focus }, rotate_with_shape: true, - flip: None, })), line: no_line(), @@ -515,7 +514,6 @@ 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, }) }; @@ -671,7 +669,6 @@ 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. diff --git a/crates/ui-egui/src/panels/fillui.rs b/crates/ui-egui/src/panels/fillui.rs index 3e247621..d1f807ae 100644 --- a/crates/ui-egui/src/panels/fillui.rs +++ b/crates/ui-egui/src/panels/fillui.rs @@ -21,7 +21,6 @@ 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, } } @@ -147,7 +146,6 @@ 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 {