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:
Brandon Thomas
2026-10-14 13:23:36 -04:00
5 changed files with 301 additions and 23 deletions
+2
View File
@@ -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());
+158
View File
@@ -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(())
})
}
+48
View File
@@ -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());
}
+91 -21
View File
@@ -2351,29 +2351,59 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
});
});
group(ui, |ui| {
let r = big_button(ui, Icon::QuickStyles, "Picture\nStyles", true);
egui::Popup::menu(&r).show(|ui| {
if ui.button("Simple Frame, White").clicked() {
run(app, "shape.line", json!({"color": "#FFFFFF", "width": 6}));
run(app, "shape.effects", json!({"shadow": "outer"}));
}
if ui.button("Drop Shadow Rectangle").clicked() {
run(app, "shape.effects", json!({"shadow": "bottom"}));
}
if ui.button("Rounded Diagonal Corner").clicked() {
run(app, "shape.change", json!({"preset": "round2DiagRect"}));
}
if ui.button("Soft Edge Rectangle").clicked() {
run(app, "shape.effects", json!({"softEdges": 10}));
}
if ui.button("Reflected Rounded Rectangle").clicked() {
run(app, "shape.change", json!({"preset": "roundRect"}));
run(app, "shape.effects", json!({"reflection": "half"}));
}
if ui.button("Oval").clicked() {
run(app, "shape.change", json!({"preset": "ellipse"}));
use deckcraft_engine::cmd::picture_style::{STYLES, look};
let names: Vec<String> = STYLES.iter().map(|(_, l)| l.to_string()).collect();
if let Some(i) = widgets::gallery(
ui,
STYLES.len(),
vec2(56.0, 44.0),
None,
|p, r, i| paint_picture_style(p, r, STYLES.get(i).and_then(|(id, _)| look(id))),
|i| names.get(i).cloned().unwrap_or_default(),
) && let Some((id, _)) = STYLES.get(i)
{
run(app, "picture.style", json!({"style": id}));
}
});
group(ui, |ui| {
let sc = scheme(app);
let (m, a) = color_split(ui, Icon::QuickStyles, "Picture Border", Color32::BLACK, true);
if m.clicked() {
run(app, "shape.line", json!({"color": "#000000", "width": 1.0}));
}
widgets::color_popup(&a).show(|ui| {
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) {
match c {
Some(c) => run(app, "shape.line", json!({"color": cref_param(&c)})),
None => run(app, "shape.line", json!({"none": true})),
}
}
ui.separator();
ui.menu_button("Weight", |ui| {
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
if ui.button(format!("{w} pt")).clicked() {
run(app, "shape.line", json!({"width": w}));
}
}
});
ui.menu_button("Dashes", |ui| {
for (l, d) in [
("Solid", "solid"),
("Round Dot", "sysDot"),
("Square Dot", "sysDash"),
("Dash", "dash"),
("Dash Dot", "dashDot"),
("Long Dash", "lgDash"),
] {
if ui.button(l).clicked() {
run(app, "shape.line", json!({"dash": d}));
}
}
});
});
if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() {
run(app, "picture.style", json!({"style": "none"}));
}
big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"}));
});
arrange_group(app, ui);
@@ -2487,6 +2517,46 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) {
});
}
/// A Picture Styles gallery tile: a landscape "picture" in the style's shape and frame.
fn paint_picture_style(p: &egui::Painter, r: Rect, lk: Option<deckcraft_engine::cmd::picture_style::Look>) {
let Some(lk) = lk else { return };
let r = r.shrink(7.0);
if lk.shadow.is_some() {
p.rect_filled(r.translate(vec2(2.0, 2.0)), CornerRadius::same(2), Color32::from_black_alpha(60));
}
let sky = Color32::from_rgb(0x7F, 0xB2, 0xE5);
let frame = lk.frame.map(|(c, w)| Stroke::new((w as f32 / 2.5).clamp(1.0, 4.0), theme::to_color32(c)));
let edge = Stroke::new(0.5, Color32::from_gray(150));
match lk.preset {
"ellipse" => {
p.add(egui::Shape::ellipse_filled(r.center(), r.size() / 2.0, sky));
p.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, frame.unwrap_or(edge)));
}
preset => {
let round = if preset == "rect" { 0 } else { 6 };
p.rect_filled(r, CornerRadius::same(round), sky);
p.rect_stroke(r, CornerRadius::same(round), frame.unwrap_or(edge), egui::StrokeKind::Middle);
}
}
// Hills, so the tile reads as a picture.
let base = r.bottom() - r.height() * 0.15;
p.line_segment(
[pos2(r.left() + r.width() * 0.2, base), pos2(r.center().x, r.top() + r.height() * 0.45)],
Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)),
);
p.line_segment(
[pos2(r.center().x, r.top() + r.height() * 0.45), pos2(r.right() - r.width() * 0.2, base)],
Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)),
);
if lk.reflection {
p.rect_filled(
Rect::from_min_max(pos2(r.left(), r.bottom() + 1.0), pos2(r.right(), r.bottom() + 5.0)),
CornerRadius::ZERO,
sky.gamma_multiply(0.3),
);
}
}
pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) {
let slot = match id.rsplit('-').next().unwrap_or("accent1") {
"accent2" => deckcraft_color::SchemeSlot::Accent2,
+2 -2
View File
@@ -27,7 +27,7 @@ The summary between `<!-- SUMMARY -->
| Tables | 89% |
| Charts | 65% |
| SmartArt | 33% |
| Pictures | 48% |
| Pictures | 52% |
| Media | 67% |
| Transitions | 92% |
| Animations | 79% |
@@ -186,7 +186,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Pictures | Corrections, colour, transparency | P1 | P |
| Pictures | Artistic effects | P2 | M |
| Pictures | Remove background | P2 | M |
| Pictures | Picture styles and borders | P1 | P |
| Pictures | Picture styles and borders | P1 | D |
| Pictures | Change / reset picture | P1 | D |
| Pictures | Compress pictures | P2 | M |
| Pictures | Paste image from clipboard (fails on Windows and Linux, #53) | P0 | P |