Files
deckcraft/crates/engine/src/cmd/shape/appearance.rs
T

575 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
};
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(0).min(16);
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
edit_geometry(s, p, "shape.adjust", |sh| {
if let Geom::Preset { name, adj } = &mut sh.geom {
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
while adj.len() <= i {
let k = adj.len();
adj.push(defaults.get(k).copied().unwrap_or(0.0));
}
if let Some(slot) = adj.get_mut(i) {
*slot = v;
}
}
Ok(())
})
}
/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed
/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says.
pub(super) fn smooth_corners(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "shape.smoothCorners";
let smoothing =
f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0;
let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0));
edit_geometry(s, p, CMD, |sh| {
let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) };
let ss = x.w.min(x.h).max(0.0);
let own = match &sh.geom {
Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0),
Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0),
_ => None,
};
// A custom outline qualifies only when it is a squircle this command drew with `radius`
// (re-smoothing as the slider moves), never an arbitrary freeform.
let custom = match (&sh.geom, radius) {
(Geom::Custom { paths }, Some(r)) => match paths.as_slice() {
[only] => (0..=100).any(|k| deckcraft_geom::squircle::path_d(only.w, only.h, r, f64::from(k) / 100.0) == only.d),
_ => false,
},
_ => false,
};
let r = match (radius, own) {
(Some(r), _) if own.is_some() || custom => r,
(None, Some(r)) => r,
_ => return Err(bad(CMD, "only rectangles, rounded rectangles and shapes already smoothed with this `radius` can be smoothed")),
};
sh.geom = Geom::Custom {
paths: vec![deckcraft_model::CustomPath {
w: x.w,
h: x.h,
d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing),
..Default::default()
}],
};
Ok(())
})
}
pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
edit_geometry(s, p, "shape.change", |sh| {
sh.geom = Geom::preset(info.name);
Ok(())
})
}
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
/// rotateWithShape?}`.
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v
.get("stops")?
.as_array()?
.iter()
.filter_map(|s| {
let a = s.as_array()?;
Some(GradientStop { pos: a.first()?.as_f64()?.clamp(0.0, 1.0), color: color_value(a.get(1)?)? })
})
.collect();
if stops.len() < 2 {
return None;
}
let focus = v
.get("focus")
.and_then(Value::as_array)
.and_then(|a| {
let f = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0));
Some([f(0)?, f(1)?, f(2)?, f(3)?])
})
.unwrap_or([0.5, 0.5, 0.5, 0.5]);
let mut stops = stops;
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
let path = |p: &str| GradientShape::Path { path: p.into(), focus };
let shape = match v.get("kind").and_then(Value::as_str) {
Some("radial" | "circle") => path("circle"),
Some("rectangular" | "rect") => path("rect"),
Some("path" | "shape") => path("shape"),
_ => GradientShape::Linear {
angle: v.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).unwrap_or(90.0).rem_euclid(360.0),
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: 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
/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit.
fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> {
if depth > 64 {
return Ok(());
}
f(sh)?;
if let Some(children) = sh.children_mut() {
for child in children {
apply(child, depth + 1, f)?;
}
}
Ok(())
}
edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f))
}
pub(super) fn fill(s: &mut Session, p: &Value) -> Result<Value> {
let new_fill = requested_fill(s, p)?;
let transparency = f64_param(p, "transparency");
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
return Err(bad(
"shape.fill",
"give `color`, `none`, `background`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`",
));
}
// Transparency on an inherited (style) fill needs the effective colour.
let inherited = inherited_fills(s, p)?;
let background = matches!(new_fill, Some(Some(Fill::Background)));
if background {
let st = s.doc()?;
let mut any = false;
for id in targets(s, p)? {
if let Some(sh) = st.shape(id) {
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| any |= matches!(sh.kind, deckcraft_model::ShapeKind::Shape));
}
}
if !any {
return Err(bad("shape.fill", "slide background fill applies to shapes and text boxes"));
}
}
edit_group_appearance(s, p, "shape.fill", |sh| {
if background && !matches!(sh.kind, deckcraft_model::ShapeKind::Shape) {
if matches!(sh.kind, deckcraft_model::ShapeKind::Group { .. }) && !group_fill_used(sh, 0) {
sh.fill = None;
}
return Ok(());
}
apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited)
})
}
/// Only a shape or text box (`p:sp`) saves a slide background fill (`useBgFill`). Pictures,
/// connectors, tables and charts keep their fill, and a group's own fill is cleared unless one
/// of them is filled with it (`grpFill`).
fn group_fill_used(sh: &deckcraft_model::Shape, depth: usize) -> bool {
depth <= 64
&& sh.children().iter().any(|c| match c.kind {
deckcraft_model::ShapeKind::Shape => false,
deckcraft_model::ShapeKind::Group { .. } => matches!(c.fill, None | Some(Fill::Group)) && group_fill_used(c, depth + 1),
_ => c.fill == Some(Fill::Group),
})
}
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
pub(super) fn picture_props(pf: &mut PictureFill, pp: &Value) {
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
pf.upright = !up;
}
match pp.get("tile").and_then(Value::as_bool) {
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
}
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
_ => {}
}
match &mut pf.mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
if let Some(v) = f("offsetX") {
*tx = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("offsetY") {
*ty = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("scaleX") {
*sx = v.clamp(0.01, 10.0);
}
if let Some(v) = f("scaleY") {
*sy = v.clamp(0.01, 10.0);
}
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
*flip = m.to_string();
}
if let Some(a) =
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
{
*align = a.to_string();
}
}
PictureMode::Stretch { fill_rect } => {
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
for (slot, v) in fill_rect.iter_mut().zip(arr) {
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
*slot = v.clamp(-10.0, 0.99);
}
}
}
}
}
}
fn line_end(v: &str) -> Option<LineEnd> {
if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) }
}
pub(super) fn line(s: &mut Session, p: &Value) -> Result<Value> {
let reset = bool_or(p, "reset", false);
let none = bool_or(p, "none", false);
let color = color_param(p, "color");
let width = f64_param(p, "width").map(|w| w.clamp(0.0, 1584.0));
let dash = str_param(p, "dash").map(|d| Dash::Preset { name: d.to_string() });
let head = str_param(p, "head").map(line_end);
let tail = str_param(p, "tail").map(line_end);
let cap = str_param(p, "cap").map(|c| match c {
"round" => deckcraft_model::style::LineCap::Round,
"square" => deckcraft_model::style::LineCap::Square,
_ => deckcraft_model::style::LineCap::Flat,
});
let compound = str_param(p, "compound").map(|c| match c {
"double" => deckcraft_model::style::Compound::Double,
"thickThin" => deckcraft_model::style::Compound::ThickThin,
"thinThick" => deckcraft_model::style::Compound::ThinThick,
"triple" => deckcraft_model::style::Compound::Triple,
_ => deckcraft_model::style::Compound::Single,
});
let end_size = |k: &str| str_param(p, k).filter(|v| matches!(*v, "sm" | "med" | "lg")).map(str::to_string);
let (head_size, tail_size) = (end_size("headSize"), end_size("tailSize"));
let join = str_param(p, "join").map(|c| match c {
"bevel" => deckcraft_model::style::LineJoin::Bevel,
"miter" => deckcraft_model::style::LineJoin::Miter,
_ => deckcraft_model::style::LineJoin::Round,
});
edit_group_appearance(s, p, "shape.line", |sh| {
if reset {
sh.line = None;
return Ok(());
}
let l = sh.line.get_or_insert_with(Line::default);
if none {
l.fill = Some(Fill::None);
}
if let Some(c) = &color {
l.fill = Some(Fill::solid(c.clone()));
}
if width.is_some() {
l.width = width;
if l.fill.is_none() || matches!(l.fill, Some(Fill::None)) && !none {
l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)));
}
}
if dash.is_some() {
l.dash.clone_from(&dash);
}
let keep_size = |old: &Option<LineEnd>, new: &Option<LineEnd>| {
new.clone().map(|mut n| {
if let Some(o) = old {
n.w.clone_from(&o.w);
n.len.clone_from(&o.len);
}
n
})
};
if let Some(h) = &head {
l.head = keep_size(&l.head, h);
}
if let Some(t) = &tail {
l.tail = keep_size(&l.tail, t);
}
if cap.is_some() {
l.cap = cap;
}
if join.is_some() {
l.join = join;
}
if compound.is_some() {
l.compound = compound;
}
// Arrowhead size (width and length together), kept when the kind changes.
for (end, size) in [(&mut l.head, &head_size), (&mut l.tail, &tail_size)] {
if let (Some(e), Some(sz)) = (end.as_mut(), size) {
e.w.clone_from(sz);
e.len.clone_from(sz);
}
}
Ok(())
})
}
fn shadow_preset(v: &Value) -> Option<Option<Shadow>> {
let black = |a: i32| ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(a));
let base = |inner: bool| Shadow {
color: black(40000),
blur: 4.0,
dist: 3.0,
dir: 45.0,
inner,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: "tl".into(),
rotate_with_shape: false,
};
match v {
Value::String(s) => Some(match s.as_str() {
"none" => None,
"inner" => Some(base(true)),
"perspective" => Some(Shadow { sy: 0.23, kx: -1200000.0 / 60000.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base(false) }),
"bottom" => Some(Shadow { dir: 90.0, ..base(false) }),
"center" => Some(Shadow { dist: 0.0, blur: 8.0, ..base(false) }),
_ => Some(base(false)),
}),
Value::Object(o) => Some(Some(Shadow {
color: o.get("color").and_then(color_value).unwrap_or(black(40000)),
blur: o.get("blur").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.0, 200.0),
dist: o.get("dist").and_then(Value::as_f64).unwrap_or(3.0).clamp(0.0, 200.0),
dir: o.get("dir").and_then(Value::as_f64).unwrap_or(45.0),
inner: o.get("inner").and_then(Value::as_bool).unwrap_or(false),
..base(false)
})),
_ => None,
}
}
pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
let reset = bool_or(p, "reset", false);
let shadow = p.get("shadow").and_then(shadow_preset);
let glow: Option<Option<Glow>> = p.get("glow").map(|g| match g {
Value::Object(o) => Some(Glow {
color: o.get("color").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000))),
radius: o.get("radius").and_then(Value::as_f64).unwrap_or(8.0).clamp(0.0, 150.0),
}),
Value::Number(n) => Some(Glow {
color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)),
radius: n.as_f64().unwrap_or(8.0).clamp(0.0, 150.0),
}),
_ => None,
});
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
let refl = str_param(p, "reflection").map(|r| match r {
"none" => None,
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
});
let three_d = p.get("threeD").filter(|v| v.is_object());
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
let shadow_props = p.get("shadowProps").filter(|v| v.is_object()).cloned();
let glow_props = p.get("glowProps").filter(|v| v.is_object()).cloned();
let refl_props = p.get("reflectionProps").filter(|v| v.is_object()).cloned();
edit_shapes(s, p, "shape.effects", |sh| {
if reset {
sh.effects = None;
return Ok(());
}
let e = sh.effects.get_or_insert_with(Effects::default);
if let Some(sw) = &shadow {
match sw {
Some(x) if x.inner => {
e.inner_shadow = Some(x.clone());
e.outer_shadow = None;
}
Some(x) => {
e.outer_shadow = Some(x.clone());
e.inner_shadow = None;
}
None => {
e.outer_shadow = None;
e.inner_shadow = None;
}
}
}
if let Some(g) = &glow {
e.glow.clone_from(g);
}
if let Some(sf) = soft {
e.soft_edge = sf;
}
if let Some(r) = &refl {
e.reflection.clone_from(r);
}
if bevel.is_some() || three_d.is_some() {
let mut s3 = e.raw3d.as_deref().map(crate::cmd::three_d::Shape3d::parse).unwrap_or_default();
if let Some(b) = &bevel {
s3.apply(&json!({"bevelTop": b.as_deref().unwrap_or("none")}));
}
if let Some(t) = three_d {
if t.get("reset").and_then(Value::as_bool) == Some(true) {
s3 = crate::cmd::three_d::Shape3d::default();
}
s3.apply(t);
}
crate::cmd::three_d::store(e, &s3);
}
if let Some(v) = &shadow_props {
let slot = if e.inner_shadow.is_some() { &mut e.inner_shadow } else { &mut e.outer_shadow };
let mut x = slot.take().or_else(|| shadow_preset(&json!("outer")).flatten()).ok_or_else(|| bad("shape.effects", "no shadow preset"))?;
if let Some(c) = v.get("color").and_then(color_value) {
let a = x.color.alpha();
x.color = with_alpha(c, a);
}
if let Some(t) = num(v, "transparency") {
x.color = with_alpha(x.color.clone(), 1.0 - t.clamp(0.0, 1.0));
}
if let Some(b) = num(v, "blur") {
x.blur = b.clamp(0.0, 100.0);
}
if let Some(d) = num(v, "dist") {
x.dist = d.clamp(0.0, 200.0);
}
if let Some(d) = num(v, "dir") {
x.dir = d.rem_euclid(360.0);
}
// PowerPoint's Size: 0–200%, both axes; a zero scale would collapse the shadow's matrix.
if let Some(z) = num(v, "size") {
let z = z.clamp(0.01, 2.0);
x.sx = z;
x.sy = z;
}
*slot = Some(x);
}
if let Some(v) = &glow_props {
let mut g =
e.glow.take().unwrap_or(Glow { color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)), radius: 8.0 });
if let Some(c) = v.get("color").and_then(color_value) {
let a = g.color.alpha();
g.color = with_alpha(c, a);
}
if let Some(t) = num(v, "transparency") {
g.color = with_alpha(g.color.clone(), 1.0 - t.clamp(0.0, 1.0));
}
if let Some(r) = num(v, "radius") {
g.radius = r.clamp(0.0, 150.0);
}
e.glow = (g.radius > 0.0).then_some(g);
}
if let Some(v) = &refl_props {
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
if let Some(t) = num(v, "transparency") {
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
}
if let Some(sz) = num(v, "size") {
r.end_pos = sz.clamp(0.0, 1.0);
}
if let Some(d) = num(v, "dist") {
r.dist = d.clamp(0.0, 100.0);
}
if let Some(b) = num(v, "blur") {
r.blur = b.clamp(0.0, 100.0);
}
e.reflection = Some(r);
}
Ok(())
})
}
/// A finite number at `key` of an effect's properties.
fn num(v: &Value, key: &str) -> Option<f64> {
v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite())
}
/// `c` with its alpha set to `a` (0–1); fully opaque drops the transform.
fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef {
c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
if a < 1.0 {
c.mods.push(ColorTransform::Alpha((a.clamp(0.0, 1.0) * 100_000.0).round() as i32));
}
c
}
/// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents.
pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>) {
let slots = [
SchemeSlot::Dk1,
SchemeSlot::Accent1,
SchemeSlot::Accent2,
SchemeSlot::Accent3,
SchemeSlot::Accent4,
SchemeSlot::Accent5,
SchemeSlot::Accent6,
];
let slot = slots.get(index % 7).copied().unwrap_or(SchemeSlot::Accent1);
let row = index / 7;
let mut st = ShapeStyle::accent(slot);
let c = ColorRef::scheme(slot);
match row {
0 => {
// Colored outline, light fill.
st.fill_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
st.line_ref = (2, c);
st.font_ref.1 = Some(ColorRef::scheme(SchemeSlot::Dk1));
(st, None, None)
}
1 => (st, None, None),
2 => {
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(20000)).with(ColorTransform::LumOff(80000)));
st.line_ref = (0, c.clone());
st.font_ref.1 = Some(c.with(ColorTransform::LumMod(50000)));
(st, None, Some(Line::none()))
}
3 => {
st.fill_ref = (2, c);
(st, None, Some(Line::none()))
}
4 => {
st.fill_ref = (3, c.clone());
st.effect_ref = (3, c);
(st, None, Some(Line::none()))
}
_ => {
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(75000)));
st.line_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
(st, None, None)
}
}
}
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(1).min(41);
let (st, f, l) = quick_style_for(i);
edit_shapes(s, p, "shape.quickStyle", |sh| {
sh.style = Some(st.clone());
sh.fill.clone_from(&f);
sh.line.clone_from(&l);
sh.effects = None;
if let Some(t) = sh.text.as_mut() {
deckcraft_model::edit::format_all(t, &|r| r.fill = None);
}
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));
}
}