diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..5a03b1b0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/picture_style.rs b/crates/engine/src/cmd/picture_style.rs new file mode 100644 index 00000000..a6a7380f --- /dev/null +++ b/crates/engine/src/cmd/picture_style.rs @@ -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 { + 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::>() + ))), + ] +} + +/// (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, + pub reflection: bool, + pub bevel: Option<&'static str>, + pub rot: Option, +} + +pub fn look(id: &str) -> Option { + 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 { + 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(()) + }) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..deb5e6cb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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()); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..ed4d4271 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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 = 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) { + 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, diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..fbf8d75f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 ` -**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 ` | 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 |