mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 17:45:10 +00:00
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.
This commit is contained in:
@@ -107,38 +107,45 @@ pub(crate) fn fill(e: &El, theme: &[Rgb], ph: Option<(Rgb, f32)>) -> Option<Fill
|
||||
"a:solidFill" => 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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ pub(crate) fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn some_yes() -> Option<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
macro_rules! id_type {
|
||||
($(#[$doc:meta])* $name:ident) => {
|
||||
$(#[$doc])*
|
||||
|
||||
@@ -121,7 +121,7 @@ pub fn pattern(f: &El) -> PatternFill {
|
||||
}
|
||||
|
||||
pub fn gradient(f: &El) -> Gradient {
|
||||
let mut stops: Vec<GradientStop> = f
|
||||
let stops: Vec<GradientStop> = 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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GradientStop>,
|
||||
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<GradientShape>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flip: Option<GradientFlip>,
|
||||
#[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<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.
|
||||
@@ -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<GradientStop> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
};
|
||||
|
||||
@@ -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#"<a:sysClr val="windowText" lastClr="123456"/>"#);
|
||||
assert_eq!(write(ColorBase::System { name: "3dLight".into(), last }), r#"<a:sysClr val="3dLight" lastClr="123456"/>"#);
|
||||
}
|
||||
|
||||
/// 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#"<a:gradFill xmlns:a="x"><a:gsLst><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs><a:gs pos="0"><a:srgbClr val="FF0000"/></a:gs></a:gsLst></a:gradFill>"#;
|
||||
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::<Vec<_>>(), [1.0, 0.0], "file order");
|
||||
assert_eq!(g.sorted_stops().iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 1.0]);
|
||||
let mut w = Writer::new();
|
||||
gradient(&mut w, &g);
|
||||
let out = w.finish();
|
||||
assert!(out.starts_with("<a:gradFill>"), "{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("<a:gradFill>", r#"<a:gradFill xmlns:a="x">"#).as_bytes()).unwrap());
|
||||
assert_eq!(back, g);
|
||||
// Stated ones are kept.
|
||||
let src = br#"<a:gradFill xmlns:a="x" rotWithShape="0"><a:lin ang="0" scaled="1"/></a:gradFill>"#;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user