Merge pull request #1591

Preserve gradient flip mode in PPTX

# Conflicts:
#	crates/pptx/src/read/dml.rs
#	crates/pptx/src/write/dml.rs
#	crates/pptx/tests/it/roundtrip.rs
This commit is contained in:
Brandon Thomas
2026-10-31 00:56:34 -04:00
13 changed files with 136 additions and 17 deletions
+18 -1
View File
@@ -108,7 +108,13 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
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: 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<Value> {
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));
}
}
+1
View File
@@ -99,6 +99,7 @@ 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() });
+32
View File
@@ -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<Self> {
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<GradientStop>,
pub shape: GradientShape,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flip: Option<GradientFlip>,
#[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
+7 -2
View File
@@ -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() };
+1
View File
@@ -115,6 +115,7 @@ pub fn default_format() -> FormatScheme {
],
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
flip: None,
})
};
FormatScheme {
+9 -3
View File
@@ -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, LineAlign, LineCap, LineEnd, LineJoin,
PatternFill, PictureFill, PictureMode, Reflection, Shadow,
ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineAlign, 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.
+2 -2
View File
@@ -2,7 +2,7 @@
use deckcraft_geom::{Xfrm, pt_to_emu};
use deckcraft_model::style::{
ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientShape, Line, LineAlign, LineCap, LineJoin, PictureFill, PictureMode,
ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientFlip, GradientShape, Line, LineAlign, LineCap, LineJoin, PictureFill, PictureMode,
};
use deckcraft_model::text::{
Action, AutoFit, BodyProps, Bullet, Caps, Hyperlink, ListStyle, ParaProps, Paragraph, RunKind, RunProps, Spacing, Strike, TextBody,
@@ -285,7 +285,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 {
+2
View File
@@ -46,6 +46,7 @@ fn gradient(angle: f64) -> Fill {
shape: GradientShape::Linear { angle, scaled: true },
rotate_with_shape: true,
tile: [0.0; 4],
flip: None,
})
}
@@ -116,6 +117,7 @@ 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);
+4 -1
View File
@@ -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,7 @@ 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 +150,7 @@ 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"));
+13 -2
View File
@@ -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 {
@@ -575,6 +577,15 @@ fn line_alignment_survives_two_pptx_round_trips() {
assert_eq!(deckcraft_model::style::LineAlign::from_xml("out"), None);
}
#[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();
+5
View File
@@ -978,6 +978,7 @@ 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 +1004,7 @@ 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 +1046,7 @@ 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 +1148,7 @@ 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 +1310,7 @@ 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()
+17 -3
View File
@@ -186,6 +186,7 @@ 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 +311,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 +378,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 +514,7 @@ 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 +669,7 @@ 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 {
+25 -3
View File
@@ -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};
@@ -21,21 +21,25 @@ 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,
}
}
/// The command parameter for `g` (`shape.fill {gradient}` / `design.background {gradient}`).
pub fn gradient_json(g: &Gradient) -> Value {
let stops: Vec<Value> = 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})
}
}
}
@@ -142,6 +146,7 @@ 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) }],
@@ -508,3 +513,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")));
}
}