Pictures: Picture Styles gallery and Picture Border

Picture Styles was a menu of six names, each running two or three
separate commands that left the previous style's effects behind. New
picture.style applies one of PowerPoint's 24 picture styles (frames,
shadows, soft edges, reflections, bevels, rounded, snipped and oval
shapes, a rotated frame) as one step that replaces the whole previous
look; picture.styles lists them and "none" clears the style. Picture
Format shows them as a thumbnail gallery, and a Picture Border split
button sets outline colour, weight and dashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:27:32 +03:00
co-authored by Cursor
parent 457406a7a5
commit c4c56a6a8b
5 changed files with 303 additions and 25 deletions
+2
View File
@@ -10,6 +10,7 @@ pub mod insert;
pub mod inspect;
pub mod media;
pub mod merge;
pub mod picture_style;
pub mod review;
pub mod selection;
pub mod shape;
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(slide::specs());
v.extend(insert::specs());
v.extend(shape::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
@@ -835,3 +835,51 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}
#[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
@@ -2090,29 +2090,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);
@@ -2226,6 +2256,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,
+4 -4
View File
@@ -3,12 +3,12 @@
> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
The summary between `<!-- SUMMARY -->
**Weighted breadth parity: 79%** over 191 features.
**Weighted breadth parity: 80%** over 191 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 72 | 61 | 11 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P1 | 65 | 39 | 20 | 6 | 75% |
| P2 | 54 | 13 | 15 | 26 | 38% |
| Area | Parity |
@@ -25,7 +25,7 @@ The summary between `<!-- SUMMARY -->
| Tables | 89% |
| Charts | 65% |
| SmartArt | 33% |
| Pictures | 48% |
| Pictures | 52% |
| Media | 67% |
| Transitions | 92% |
| Animations | 79% |
@@ -184,7 +184,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 |