mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-11 16:37:06 -06:00
Merge PR #545 from NikitkaRa: Pictures: Picture Styles gallery and Picture Border
# Conflicts: # crates/engine/src/cmd/mod.rs # crates/engine/src/tests.rs
This commit is contained in:
@@ -13,6 +13,7 @@ pub mod inspect;
|
||||
pub mod media;
|
||||
pub mod merge;
|
||||
pub mod points;
|
||||
pub mod picture_style;
|
||||
pub mod review;
|
||||
pub mod selection;
|
||||
pub mod shape;
|
||||
@@ -134,6 +135,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(insert::specs());
|
||||
v.extend(shape::specs());
|
||||
v.extend(background::specs());
|
||||
v.extend(picture_style::specs());
|
||||
v.extend(arrange::specs());
|
||||
v.extend(selection::specs());
|
||||
v.extend(merge::specs());
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Picture Format ▸ Picture Styles: PowerPoint's quick styles for pictures, each a frame (shape,
|
||||
//! outline) and effects applied as one step, replacing the previous style.
|
||||
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::style::{Effects, Reflection, Shadow};
|
||||
use deckcraft_model::{ColorRef, Geom, Line, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"picture.style",
|
||||
"Picture Styles",
|
||||
["Picture Format", "Picture Styles"],
|
||||
None,
|
||||
"{style: simpleFrameWhite|dropShadowRectangle|softEdgeOval|… | none (see picture.styles), ids?}",
|
||||
has_selection,
|
||||
style
|
||||
),
|
||||
cmd!(query "picture.styles", "Picture Style Gallery", [], None, "{} → [{id, label}]", always, |_s, _p| Ok(json!(
|
||||
STYLES.iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()
|
||||
))),
|
||||
]
|
||||
}
|
||||
|
||||
/// (id, label), in PowerPoint's gallery order.
|
||||
pub const STYLES: [(&str, &str); 24] = [
|
||||
("simpleFrameWhite", "Simple Frame, White"),
|
||||
("beveledMatteWhite", "Beveled Matte, White"),
|
||||
("metalFrame", "Metal Frame"),
|
||||
("dropShadowRectangle", "Drop Shadow Rectangle"),
|
||||
("roundedDiagonalCornerWhite", "Rounded Diagonal Corner, White"),
|
||||
("snipDiagonalCornerWhite", "Snip Diagonal Corner, White"),
|
||||
("moderateFrameWhite", "Moderate Frame, White"),
|
||||
("rotatedWhite", "Rotated, White"),
|
||||
("perspectiveShadowWhite", "Perspective Shadow, White"),
|
||||
("softEdgeRectangle", "Soft Edge Rectangle"),
|
||||
("doubleFrameBlack", "Double Frame, Black"),
|
||||
("thickMatteBlack", "Thick Matte, Black"),
|
||||
("simpleFrameBlack", "Simple Frame, Black"),
|
||||
("beveledOvalBlack", "Beveled Oval, Black"),
|
||||
("compoundFrameBlack", "Compound Frame, Black"),
|
||||
("centerShadowRectangle", "Center Shadow Rectangle"),
|
||||
("softEdgeOval", "Soft Edge Oval"),
|
||||
("reflectedRoundedRectangle", "Reflected Rounded Rectangle"),
|
||||
("reflectedBevelWhite", "Reflected Bevel, White"),
|
||||
("metalRoundedRectangle", "Metal Rounded Rectangle"),
|
||||
("moderateFrameBlack", "Moderate Frame, Black"),
|
||||
("bevelRectangle", "Bevel Rectangle"),
|
||||
("bevelPerspective", "Bevel Perspective"),
|
||||
("metalOval", "Metal Oval"),
|
||||
];
|
||||
|
||||
/// The look of a style: geometry, outline (colour, width) and effects.
|
||||
pub struct Look {
|
||||
pub preset: &'static str,
|
||||
pub frame: Option<(Rgba, f64)>,
|
||||
pub shadow: Option<&'static str>,
|
||||
pub soft_edge: Option<f64>,
|
||||
pub reflection: bool,
|
||||
pub bevel: Option<&'static str>,
|
||||
pub rot: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn look(id: &str) -> Option<Look> {
|
||||
let white = Rgba::rgb(0xFF, 0xFF, 0xFF);
|
||||
let black = Rgba::rgb(0, 0, 0);
|
||||
let silver = Rgba::rgb(0xC0, 0xC0, 0xC0);
|
||||
let l = |preset, frame, shadow| Look { preset, frame, shadow, soft_edge: None, reflection: false, bevel: None, rot: None };
|
||||
Some(match id {
|
||||
"simpleFrameWhite" => l("rect", Some((white, 7.0)), Some("soft")),
|
||||
"beveledMatteWhite" => Look { bevel: Some("circle"), ..l("rect", Some((white, 6.0)), Some("soft")) },
|
||||
"metalFrame" => Look { bevel: Some("angle"), ..l("rect", Some((silver, 7.0)), Some("soft")) },
|
||||
"dropShadowRectangle" => l("rect", None, Some("drop")),
|
||||
"roundedDiagonalCornerWhite" => l("round2DiagRect", Some((white, 6.0)), Some("soft")),
|
||||
"snipDiagonalCornerWhite" => l("snip2DiagRect", Some((white, 6.0)), Some("soft")),
|
||||
"moderateFrameWhite" => l("rect", Some((white, 4.5)), Some("soft")),
|
||||
"rotatedWhite" => Look { rot: Some(356.0), ..l("rect", Some((white, 6.0)), Some("soft")) },
|
||||
"perspectiveShadowWhite" => l("rect", Some((white, 4.0)), Some("perspective")),
|
||||
"softEdgeRectangle" => Look { soft_edge: Some(10.0), ..l("rect", None, None) },
|
||||
"doubleFrameBlack" => l("rect", Some((black, 6.0)), None),
|
||||
"thickMatteBlack" => l("rect", Some((black, 10.0)), Some("soft")),
|
||||
"simpleFrameBlack" => l("rect", Some((black, 5.0)), None),
|
||||
"beveledOvalBlack" => Look { bevel: Some("circle"), ..l("ellipse", Some((black, 4.0)), Some("soft")) },
|
||||
"compoundFrameBlack" => l("rect", Some((black, 4.5)), None),
|
||||
"centerShadowRectangle" => l("rect", None, Some("center")),
|
||||
"softEdgeOval" => Look { soft_edge: Some(12.0), ..l("ellipse", None, None) },
|
||||
"reflectedRoundedRectangle" => Look { reflection: true, ..l("roundRect", None, None) },
|
||||
"reflectedBevelWhite" => Look { reflection: true, bevel: Some("circle"), ..l("rect", Some((white, 4.0)), None) },
|
||||
"metalRoundedRectangle" => Look { bevel: Some("angle"), ..l("roundRect", Some((silver, 6.0)), Some("soft")) },
|
||||
"moderateFrameBlack" => l("rect", Some((black, 4.5)), Some("soft")),
|
||||
"bevelRectangle" => Look { bevel: Some("angle"), ..l("rect", None, Some("soft")) },
|
||||
"bevelPerspective" => Look { bevel: Some("relaxedInset"), ..l("rect", None, Some("perspective")) },
|
||||
"metalOval" => Look { bevel: Some("angle"), ..l("ellipse", Some((silver, 6.0)), Some("soft")) },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn shadow(kind: &str) -> Shadow {
|
||||
let base = Shadow {
|
||||
color: ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(40000)),
|
||||
blur: 4.0,
|
||||
dist: 3.0,
|
||||
dir: 45.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: "tl".into(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
match kind {
|
||||
"drop" => Shadow { blur: 12.0, dist: 6.0, dir: 90.0, ..base },
|
||||
"center" => Shadow { blur: 16.0, dist: 0.0, ..base },
|
||||
"perspective" => Shadow { sy: 0.23, kx: -20.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base },
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply style `id` (or clear the style with `none`) to `sh`.
|
||||
pub fn apply(sh: &mut Shape, id: &str) -> bool {
|
||||
if id == "none" {
|
||||
sh.geom = Geom::preset("rect");
|
||||
sh.line = None;
|
||||
sh.effects = None;
|
||||
return true;
|
||||
}
|
||||
let Some(lk) = look(id) else { return false };
|
||||
sh.geom = Geom::preset(lk.preset);
|
||||
sh.line = Some(lk.frame.map_or_else(Line::none, |(c, w)| Line::solid(ColorRef::rgb(c), w)));
|
||||
let e = Effects {
|
||||
outer_shadow: lk.shadow.map(shadow),
|
||||
soft_edge: lk.soft_edge,
|
||||
reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
bevel: lk.bevel.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
sh.effects = (!e.is_empty()).then_some(e);
|
||||
if let (Some(r), Some(x)) = (lk.rot, sh.xfrm.as_mut()) {
|
||||
x.rot = r;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "style").ok_or_else(|| bad("picture.style", "missing `style`"))?.to_string();
|
||||
if id != "none" && look(&id).is_none() {
|
||||
return Err(bad("picture.style", format!("unknown style `{id}`; see picture.styles")));
|
||||
}
|
||||
edit_shapes(s, p, "picture.style", |sh| {
|
||||
apply(sh, &id);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -1714,3 +1714,51 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() {
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_styles_replace_each_other_in_one_step() {
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf,
|
||||
0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,
|
||||
0x82,
|
||||
];
|
||||
let mut s = session();
|
||||
let data = cmd::base64_encode(PNG);
|
||||
let id = s.execute("insert.picture", &json!({"data": data, "name": "a.png", "rect": [100.0, 100.0, 200.0, 150.0]})).unwrap()["id"].clone();
|
||||
let pic = |s: &Session| s.doc().unwrap().shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone();
|
||||
assert_eq!(s.execute("picture.styles", &json!({})).unwrap().as_array().unwrap().len(), 24);
|
||||
|
||||
s.execute("picture.style", &json!({"ids": [id], "style": "reflectedRoundedRectangle"})).unwrap();
|
||||
let p = pic(&s);
|
||||
assert_eq!(p.geom.preset_name(), Some("roundRect"));
|
||||
assert!(p.effects.as_ref().unwrap().reflection.is_some());
|
||||
|
||||
// The next style replaces the whole look: no leftover reflection.
|
||||
s.execute("picture.style", &json!({"ids": [id], "style": "simpleFrameWhite"})).unwrap();
|
||||
let p = pic(&s);
|
||||
assert_eq!(p.geom.preset_name(), Some("rect"));
|
||||
let e = p.effects.as_ref().unwrap();
|
||||
assert!(e.reflection.is_none() && e.outer_shadow.is_some());
|
||||
assert_eq!(p.line.as_ref().and_then(|l| l.width), Some(7.0));
|
||||
|
||||
s.execute("picture.style", &json!({"ids": [id], "style": "softEdgeOval"})).unwrap();
|
||||
let p = pic(&s);
|
||||
assert_eq!(p.geom.preset_name(), Some("ellipse"));
|
||||
assert_eq!(p.effects.as_ref().unwrap().soft_edge, Some(12.0));
|
||||
assert!(p.line.as_ref().is_some_and(|l| l.fill == Some(deckcraft_model::Fill::None)));
|
||||
|
||||
// Survives PPTX.
|
||||
let doc = s.doc().unwrap().doc.as_ref().clone();
|
||||
let back = deckcraft_pptx::import(&deckcraft_pptx::export(&doc).unwrap()).unwrap();
|
||||
let bp = back.slides.iter().flat_map(|sl| &sl.shapes).find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Picture { .. })).unwrap();
|
||||
assert_eq!(bp.geom.preset_name(), Some("ellipse"));
|
||||
assert_eq!(bp.effects.as_ref().and_then(|e| e.soft_edge), Some(12.0));
|
||||
|
||||
// One undo step per style; none clears; unknown is refused.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pic(&s).geom.preset_name(), Some("rect"));
|
||||
s.execute("picture.style", &json!({"ids": [id], "style": "none"})).unwrap();
|
||||
assert!(pic(&s).effects.is_none() && pic(&s).line.is_none());
|
||||
assert!(s.execute("picture.style", &json!({"ids": [id], "style": "sparkly"})).is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user