From b397ca68d7084b9d3dec2e605bb94168aa367ee4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 22:43:33 -0500 Subject: [PATCH] craft-docx: gradient fills through craft-drawingml, Word's defaults in the mapping craft-docx: gradient fills through craft-drawingml, Word's defaults in the mapping * craft-drawingml: a gradient keeps what the file says, and no more - shape is None when the file has neither a:lin nor a:path, and rotate_with_shape is None when it has no rotWithShape: each app applies its own default (PowerPoint draws such a gradient top to bottom and rotated with the shape; Word left to right and not rotated). - The stops are kept in file order, so a write gives them back as they were; Gradient::sorted_stops gives them by position for drawing. The writer writes no a:lin, a:path or rotWithShape where there is none. A model saved before reads as it did (rotateWithShape missing is Some(true)). * craft-docx: gradient fills read by craft-drawingml, with Word's defaults craft_drawingml::read::gradient gives the shape, focus, tile, flip, rotation and stop positions as the file states them; this crate applies Word's defaults where the file says nothing (a left-to-right linear gradient, not rotated with the shape) and resolves the stop colours against the document's theme and phClr, as before. The gradient fixtures read as they did. --- crates/docx/src/read/drawing_color.rs | 49 +++++++++++++++------------ crates/drawingml/src/chart/write.rs | 2 +- crates/drawingml/src/lib.rs | 4 +++ crates/drawingml/src/read.rs | 11 +++--- crates/drawingml/src/style.rs | 23 +++++++++++-- crates/drawingml/src/theme.rs | 4 +-- crates/drawingml/src/write.rs | 46 +++++++++++++++++++++++-- 7 files changed, 103 insertions(+), 36 deletions(-) diff --git a/crates/docx/src/read/drawing_color.rs b/crates/docx/src/read/drawing_color.rs index 9c9f7cf6b..d7a7b1fbb 100644 --- a/crates/docx/src/read/drawing_color.rs +++ b/crates/docx/src/read/drawing_color.rs @@ -107,38 +107,45 @@ pub(crate) fn fill(e: &El, theme: &[Rgb], ph: Option<(Rgb, f32)>) -> Option solid(Some(e)), "a:pattFill" => solid(e.child("a:fgClr")), "a:gradFill" => { - let pct = |v: Option<&str>| v.and_then(int).map_or(0.0, |v| v.clamp(-100_000_000, 100_000_000) as f32 / 1000.0); - let rect = |r: Option<&El>| r.map_or([0.0; 4], |r| ["l", "t", "r", "b"].map(|k| pct(r.attr(k)))); + // Read by craft-drawingml, which keeps what the file says; Word's defaults are applied + // here: no `a:lin`/`a:path` is a left-to-right linear gradient, and an unstated + // `rotWithShape` is off. + let g = craft_drawingml::read::gradient(&crate::xml::to_craft(e)); + let percent = |v: f64| (v * 100.0) as f32; + let rect = |r: [f64; 4]| r.map(percent); + // Stop colours resolve against the document's theme and `phClr`, so they are read + // here from the same `a:gs` elements, which craft-drawingml keeps in file order. let stops = e .child("a:gsLst") .into_iter() .flat_map(|l| l.children("a:gs")) + .zip(&g.stops) .take(MAX_STOPS * 4) - .filter_map(|g| color_alpha(g, theme, ph).map(|(color, transparency)| GradStop { pos: pct(g.attr("pos")), color, transparency })) + .filter_map(|(gs, stop)| { + color_alpha(gs, theme, ph).map(|(color, transparency)| GradStop { pos: percent(stop.pos), color, transparency }) + }) .collect(); - let on = |v: Option<&str>| v.is_some_and(|v| v == "1" || v == "true"); - let kind = match (e.child("a:path"), e.child("a:lin")) { - (Some(p), _) => { - let shape = match p.attr("path") { - Some("rect") => PathShape::Rect, - Some("shape") => PathShape::Shape, + let kind = match g.shape { + Some(craft_drawingml::style::GradientShape::Path { path, focus }) => { + let shape = match path.as_str() { + "rect" => PathShape::Rect, + "shape" => PathShape::Shape, _ => PathShape::Circle, }; - GradientKind::Path { shape, focus: rect(p.child("a:fillToRect")) } + GradientKind::Path { shape, focus: rect(focus) } } - (None, l) => GradientKind::Linear { - angle: l.and_then(|l| l.attr("ang")).and_then(int).map_or(0.0, |a| (a % 21_600_000) as f32 / 60_000.0), - scaled: on(l.and_then(|l| l.attr("scaled"))), - }, + Some(craft_drawingml::style::GradientShape::Linear { angle, scaled }) => GradientKind::Linear { angle: angle as f32, scaled }, + None => GradientKind::Linear { angle: 0.0, scaled: false }, }; - let flip = match e.attr("flip") { - Some("x") => GradientFlip::X, - Some("y") => GradientFlip::Y, - Some("xy") => GradientFlip::Xy, - _ => GradientFlip::None, + let flip = match g.flip { + Some(craft_drawingml::style::GradientFlip::X) => GradientFlip::X, + Some(craft_drawingml::style::GradientFlip::Y) => GradientFlip::Y, + Some(craft_drawingml::style::GradientFlip::Xy) => GradientFlip::Xy, + Some(craft_drawingml::style::GradientFlip::None) | None => GradientFlip::None, }; - let tile = e.child("a:tileRect").map(|r| rect(Some(r))); - Fill::Gradient(Arc::new(Gradient { stops, kind, tile, rot_with_shape: on(e.attr("rotWithShape")), flip })).sanitized() + let tile = e.child("a:tileRect").map(|_| rect(g.tile)); + let rot_with_shape = g.rotate_with_shape.unwrap_or(false); + Fill::Gradient(Arc::new(Gradient { stops, kind, tile, rot_with_shape, flip })).sanitized() } _ => None, } diff --git a/crates/drawingml/src/chart/write.rs b/crates/drawingml/src/chart/write.rs index e1b5bd915..d115c4a2c 100644 --- a/crates/drawingml/src/chart/write.rs +++ b/crates/drawingml/src/chart/write.rs @@ -45,7 +45,7 @@ fn simple_fill(w: &mut Writer, f: &Fill) { } Fill::Gradient(_) | Fill::Picture(_) | Fill::Group | Fill::Background => { if let Fill::Gradient(g) = f - && let Some(s) = g.stops.first() + && let Some(s) = g.sorted_stops().first() { dml::solid(w, &s.color); } diff --git a/crates/drawingml/src/lib.rs b/crates/drawingml/src/lib.rs index 87254bcc1..0227bd836 100644 --- a/crates/drawingml/src/lib.rs +++ b/crates/drawingml/src/lib.rs @@ -56,6 +56,10 @@ pub(crate) fn yes() -> bool { true } +pub(crate) fn some_yes() -> Option { + Some(true) +} + macro_rules! id_type { ($(#[$doc:meta])* $name:ident) => { $(#[$doc])* diff --git a/crates/drawingml/src/read.rs b/crates/drawingml/src/read.rs index e74b65d07..496870774 100644 --- a/crates/drawingml/src/read.rs +++ b/crates/drawingml/src/read.rs @@ -121,7 +121,7 @@ pub fn pattern(f: &El) -> PatternFill { } pub fn gradient(f: &El) -> Gradient { - let mut stops: Vec = f + let stops: Vec = f .child(Local("gsLst")) .map(|l| { l.children(Local("gs")) @@ -130,15 +130,14 @@ pub fn gradient(f: &El) -> Gradient { .collect() }) .unwrap_or_default(); - stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); let shape = if let Some(l) = f.child(Local("lin")) { - GradientShape::Linear { angle: ang(l, "ang").unwrap_or(0.0), scaled: l.attr_local("scaled").and_then(value::to_bool).unwrap_or(false) } + Some(GradientShape::Linear { angle: ang(l, "ang").unwrap_or(0.0), scaled: l.attr_local("scaled").and_then(value::to_bool).unwrap_or(false) }) } else if let Some(p) = f.child(Local("path")) { let r = p.child(Local("fillToRect")); let g = |n: &str| r.and_then(|r| pct(r, n)).unwrap_or(0.0); - GradientShape::Path { path: p.attr_local("path").unwrap_or("circle").to_string(), focus: [g("l"), g("t"), g("r"), g("b")] } + Some(GradientShape::Path { path: p.attr_local("path").unwrap_or("circle").to_string(), focus: [g("l"), g("t"), g("r"), g("b")] }) } else { - GradientShape::Linear { angle: 90.0, scaled: false } + None }; let tile = f.child(Local("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 { @@ -146,7 +145,7 @@ pub fn gradient(f: &El) -> Gradient { stops, shape, flip: f.attr_local("flip").and_then(GradientFlip::from_xml), - rotate_with_shape: f.attr_local("rotWithShape").and_then(value::to_bool).unwrap_or(true), + rotate_with_shape: f.attr_local("rotWithShape").and_then(value::to_bool), } } diff --git a/crates/drawingml/src/style.rs b/crates/drawingml/src/style.rs index 915214de5..291ee4e76 100644 --- a/crates/drawingml/src/style.rs +++ b/crates/drawingml/src/style.rs @@ -120,12 +120,19 @@ pub enum GradientShape { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Gradient { + /// The stops in the order the file gives them, so a write gives them back in that order. + /// [`Gradient::sorted_stops`] is them by position. pub stops: Vec, - pub shape: GradientShape, + /// `a:lin` or `a:path`. `None` when the file has neither: each reader of the model applies + /// its own default (PowerPoint draws a top-to-bottom linear gradient, Word a left-to-right one). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shape: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub flip: Option, - #[serde(default = "crate::yes")] - pub rotate_with_shape: bool, + /// `rotWithShape`. `None` when the file does not say; each reader of the model applies its own + /// default. A model saved before this was optional reads as `Some(true)`. + #[serde(default = "crate::some_yes")] + pub rotate_with_shape: Option, /// `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. @@ -133,6 +140,16 @@ pub struct Gradient { pub tile: [f64; 4], } +impl Gradient { + /// The stops by position (a stable sort: stops at one position keep their order), for + /// drawing. + pub fn sorted_stops(&self) -> Vec { + let mut stops = self.stops.clone(); + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + stops + } +} + fn is_zero4(v: &[f64; 4]) -> bool { v.iter().all(|x| *x == 0.0) } diff --git a/crates/drawingml/src/theme.rs b/crates/drawingml/src/theme.rs index 47994f830..69d7c2af6 100644 --- a/crates/drawingml/src/theme.rs +++ b/crates/drawingml/src/theme.rs @@ -109,8 +109,8 @@ pub fn default_format() -> FormatScheme { GradientStop { pos: 0.0, color: ph().with(ColorTransform::LumMod(a)).with(ColorTransform::SatMod(103000)) }, GradientStop { pos: 1.0, color: ph().with(ColorTransform::LumMod(b)) }, ], - shape: GradientShape::Linear { angle: 90.0, scaled: false }, - rotate_with_shape: true, + shape: Some(GradientShape::Linear { angle: 90.0, scaled: false }), + rotate_with_shape: Some(true), flip: None, }) }; diff --git a/crates/drawingml/src/write.rs b/crates/drawingml/src/write.rs index 8acd5d221..0d9b83b1a 100644 --- a/crates/drawingml/src/write.rs +++ b/crates/drawingml/src/write.rs @@ -294,7 +294,10 @@ fn shadow(w: &mut Writer, tag: &str, s: &crate::style::Shadow) { } fn gradient(w: &mut Writer, g: &crate::style::Gradient) { - w.tag("a:gradFill").attr_opt("flip", g.flip.map(GradientFlip::xml)).attr("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }).open(); + w.tag("a:gradFill") + .attr_opt("flip", g.flip.map(GradientFlip::xml)) + .attr_opt("rotWithShape", g.rotate_with_shape.map(|r| if r { "1" } else { "0" })) + .open(); // 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 { @@ -312,8 +315,11 @@ fn gradient(w: &mut Writer, g: &crate::style::Gradient) { w.close(); } match &g.shape { - GradientShape::Linear { angle, scaled } => w.tag("a:lin").attr("ang", ang(*angle)).attr("scaled", if *scaled { "1" } else { "0" }).empty(), - GradientShape::Path { path, focus } => { + None => {} + Some(GradientShape::Linear { angle, scaled }) => { + w.tag("a:lin").attr("ang", ang(*angle)).attr("scaled", if *scaled { "1" } else { "0" }).empty() + } + Some(GradientShape::Path { path, focus }) => { let p = match path.as_str() { "rect" | "shape" | "circle" => path.as_str(), _ => "circle", @@ -875,4 +881,38 @@ mod tests { assert_eq!(write(ColorBase::System { name: "notAColor".into(), last }), r#""#); assert_eq!(write(ColorBase::System { name: "3dLight".into(), last }), r#""#); } + + /// A gradient is written as it was read: its stops in file order, and no `a:lin`, `a:path` or + /// `rotWithShape` where the file had none (each app has its own default for them). + #[test] + fn a_gradient_writes_back_what_it_read() { + let src = br#""#; + let g = crate::read::gradient(&crate::xmlx::parse(src).unwrap()); + assert_eq!((g.shape.clone(), g.rotate_with_shape), (None, None)); + assert_eq!(g.stops.iter().map(|s| s.pos).collect::>(), [1.0, 0.0], "file order"); + assert_eq!(g.sorted_stops().iter().map(|s| s.pos).collect::>(), [0.0, 1.0]); + let mut w = Writer::new(); + gradient(&mut w, &g); + let out = w.finish(); + assert!(out.starts_with(""), "{out}"); + assert!(!out.contains("a:lin") && !out.contains("a:path"), "{out}"); + assert!(out.find("0000FF") < out.find("FF0000"), "{out}"); + let back = crate::read::gradient(&crate::xmlx::parse(out.replace("", r#""#).as_bytes()).unwrap()); + assert_eq!(back, g); + // Stated ones are kept. + let src = br#""#; + let g = crate::read::gradient(&crate::xmlx::parse(src).unwrap()); + assert_eq!((g.shape, g.rotate_with_shape), (Some(GradientShape::Linear { angle: 0.0, scaled: true }), Some(false))); + } + + /// A model saved before `shape` and `rotateWithShape` were optional reads as it did. + #[test] + fn a_gradient_saved_before_reads_the_same() { + let old = r#"{"stops":[],"shape":{"kind":"linear","angle":90.0}}"#; + let g: crate::style::Gradient = serde_json::from_str(old).unwrap(); + assert_eq!((g.shape, g.rotate_with_shape), (Some(GradientShape::Linear { angle: 90.0, scaled: false }), Some(true))); + let none = crate::style::Gradient { stops: vec![], shape: None, flip: None, rotate_with_shape: None, tile: [0.0; 4] }; + let back: crate::style::Gradient = serde_json::from_str(&serde_json::to_string(&none).unwrap()).unwrap(); + assert_eq!(back, none); + } }