From d500d045b54fe585a39dc8b5ceb4ce61e2f4ec3d Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:45:04 +0300 Subject: [PATCH 01/17] Insert: Action Settings dialog and shape.action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shapes could only get an action from an action button preset or a hyperlink to a URL or slide. Insert > Action > Action Settings… opens PowerPoint's dialog with Mouse Click and Mouse Over tabs: None, Hyperlink to (next, previous, first, last, last viewed, end show, a custom show, a slide or a URL), Run program, Play media, and Highlight click, starting from the shape's current setting. The new shape.action command sets or clears the click or hover action of the selected shapes and checks slides, custom shows and required arguments. Co-authored-by: Cursor --- crates/engine/src/cmd/insert.rs | 58 +++++++++++++++ crates/engine/src/tests.rs | 24 +++++++ crates/ui-egui/src/dialogs.rs | 122 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 5 ++ 4 files changed, 209 insertions(+) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..ca6bdb06 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -49,6 +49,15 @@ pub fn specs() -> Vec { cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number), cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time), cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol), + cmd!( + "shape.action", + "Action Settings", + ["Insert", "Links"], + None, + "{on?: click|hover, action: none|next|previous|first|last|lastViewed|endShow|playMedia|slide|url|customShow|program, slide?: index, url?, show?: custom show name, path?, highlight?: bool, tooltip?, ids?}", + has_selection, + shape_action + ), cmd!( "insert.hyperlink", "Link", @@ -599,6 +608,55 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result { }) } +/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does +/// in a show. +fn shape_action(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::text::{Action, Hyperlink}; + const CMD: &str = "shape.action"; + let hover = match str_param(p, "on").unwrap_or("click") { + "click" => false, + "hover" | "over" => true, + o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))), + }; + let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`"))); + let action = match str_param(p, "action").unwrap_or("none") { + "none" => None, + "next" => Some(Action::NextSlide), + "previous" => Some(Action::PreviousSlide), + "first" => Some(Action::FirstSlide), + "last" => Some(Action::LastSlide), + "lastViewed" => Some(Action::LastViewed), + "endShow" => Some(Action::EndShow), + "playMedia" => Some(Action::PlayMedia), + "url" => Some(Action::Url { url: need("url")? }), + "customShow" => { + let name = need("show")?; + if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) { + return Err(bad(CMD, format!("no custom show `{name}`"))); + } + Some(Action::CustomShow { name }) + } + "program" => Some(Action::Program { path: need("path")? }), + "slide" => { + let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?; + let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?; + Some(Action::Slide { slide: slide.id }) + } + a => return Err(bad(CMD, format!("unknown action `{a}`"))), + }; + let highlight = bool_param(p, "highlight").unwrap_or(true); + let tooltip = str_param(p, "tooltip").unwrap_or("").to_string(); + let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight }); + edit_shapes(s, p, CMD, |sh| { + if hover { + sh.hover = link.clone(); + } else { + sh.click = link.clone(); + } + Ok(()) + }) +} + fn action_button(s: &mut Session, p: &Value) -> Result { use deckcraft_model::text::{Action, Hyperlink}; let kind = str_param(p, "kind").unwrap_or("custom"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..ecd47563 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,27 @@ 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"); } + +/// Action Settings: click and hover actions on the selected shape, removed again with `none`. +#[test] +fn action_settings_set_click_and_hover() { + use deckcraft_model::text::Action; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap(); + let shape = |s: &Session| s.doc().unwrap().selected_shapes()[0].clone(); + s.execute("shape.action", &json!({"action": "slide", "slide": 0, "highlight": false})).unwrap(); + let first = s.doc().unwrap().doc.slides[0].id; + let sh = shape(&s); + assert!(matches!(sh.click.as_ref().map(|h| (&h.action, h.highlight_click)), Some((Action::Slide { slide }, false)) if *slide == first)); + s.execute("shape.action", &json!({"on": "hover", "action": "url", "url": "https://example.com"})).unwrap(); + assert!(matches!(shape(&s).hover.map(|h| h.action), Some(Action::Url { url }) if url == "https://example.com")); + assert!(shape(&s).click.is_some(), "hover leaves the click action"); + s.execute("shape.action", &json!({"action": "none"})).unwrap(); + assert!(shape(&s).click.is_none()); + // Bad input is refused. + assert!(s.execute("shape.action", &json!({"action": "slide", "slide": 99})).is_err()); + assert!(s.execute("shape.action", &json!({"action": "url"})).is_err()); + assert!(s.execute("shape.action", &json!({"action": "customShow", "show": "nope"})).is_err()); + assert!(s.execute("shape.action", &json!({"on": "drag", "action": "next"})).is_err()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..de4208f5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -55,6 +55,7 @@ fn title(id: &str) -> &'static str { "chartData" => "Chart Data", "trim" => "Trim Media", "smartart" => "Choose a SmartArt Graphic", + "action" => "Action Settings", "quit" => "DeckCraft", _ => "DeckCraft", } @@ -344,6 +345,127 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "action" => { + use deckcraft_model::text::Action; + let tab = d.get("tab", "click"); + // Start from the selected shape's current setting for this tab. + let (cur, slides, shows) = app + .session + .active() + .map(|st| { + let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None); + let slides: Vec<(deckcraft_model::SlideId, String)> = + st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect(); + let shows: Vec = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect(); + (sh, slides, shows) + }) + .unwrap_or_default(); + let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) { + None => ("none", String::new()), + Some(Action::NextSlide) => ("next", String::new()), + Some(Action::PreviousSlide) => ("previous", String::new()), + Some(Action::FirstSlide) => ("first", String::new()), + Some(Action::LastSlide) => ("last", String::new()), + Some(Action::LastViewed) => ("lastViewed", String::new()), + Some(Action::EndShow) => ("endShow", String::new()), + Some(Action::PlayMedia) => ("playMedia", String::new()), + Some(Action::Url { url }) => ("url", url.clone()), + Some(Action::CustomShow { name }) => ("customShow", name.clone()), + Some(Action::Program { path }) => ("program", path.clone()), + Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), + }; + ui.horizontal(|ui| { + for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] { + if ui.selectable_label(tab == k, l).clicked() && tab != k { + d.fields.clear(); + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" }); + let kind = d.get("kind", cur_kind); + let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia"); + let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| { + if ui.radio(d.get("kind", cur_kind) == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + }; + pick(d, ui, "none", "None"); + ui.horizontal(|ui| { + if ui.radio(hyperlink, "Hyperlink to:").clicked() && !hyperlink { + d.fields.insert("kind".into(), "next".into()); + } + let targets = [ + ("next", "Next Slide"), + ("previous", "Previous Slide"), + ("first", "First Slide"), + ("last", "Last Slide"), + ("lastViewed", "Last Slide Viewed"), + ("endShow", "End Show"), + ("customShow", "Custom Show…"), + ("slide", "Slide…"), + ("url", "URL…"), + ]; + let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); + ui.add_enabled_ui(hyperlink, |ui| { + egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { + for (k, l) in targets { + if ui.selectable_label(kind == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + } + }); + }); + }); + let kind = d.get("kind", cur_kind); + match kind.as_str() { + "url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }), + "slide" => { + let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + for (i, (_, t)) in slides.iter().enumerate() { + if ui.selectable_label(sel == i, t).clicked() { + d.fields.insert("arg".into(), i.to_string()); + } + } + }); + } + "customShow" => { + if shows.is_empty() { + ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show)."); + } + let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); + for n in &shows { + if ui.selectable_label(&sel == n, n).clicked() { + d.fields.insert("arg".into(), n.clone()); + } + } + } + _ => {} + } + pick(d, ui, "program", "Run program:"); + if d.get("kind", cur_kind) == "program" { + field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" }); + } + pick(d, ui, "playMedia", "Play the shape's media"); + let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click)); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let kind = d.get("kind", cur_kind); + let arg = d.get("arg", &cur_arg); + let mut p = json!({"on": tab, "action": kind, "highlight": hl}); + match kind.as_str() { + "url" => p["url"] = json!(arg), + "slide" => p["slide"] = json!(arg.parse::().unwrap_or(0)), + "customShow" => p["show"] = json!(arg), + "program" => p["path"] = json!(arg), + _ => {} + } + run(app, "shape.action", p); + } + ok || cancel + } "altText" => { let cur = app.session.active().and_then(|s| s.selected_shapes().first().map(|x| x.descr.clone())).unwrap_or_default(); ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..538da3d9 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1062,6 +1062,11 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { run(app, "insert.actionButton", json!({"kind": k})); } } + ui.separator(); + let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty()); + if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("action")); + } }); }); group(ui, |ui| { From 6e403354247e419cd9fe52be241e84ce57c58c1a Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:22:22 +0300 Subject: [PATCH 02/17] SmartArt: Text Pane, add and remove shapes, promote and demote A SmartArt group now keeps its outline (Shape.diagram: layout + nodes with levels) and its shapes are regenerated from it inside the group's child rectangle. New commands: smartArt.nodes, setNodes, addNode (after, before, above, below), removeNode, promote, demote and layout. Sub-points become bullets in their parent's shape; the hierarchy layout is a real tree with connector lines. Text typed into a diagram shape is read back first. UI: a SmartArt Design contextual tab and a Text Pane (Enter adds a line, Tab/Shift+Tab demote/promote, Backspace on an empty line removes it). Co-authored-by: Cursor --- crates/engine/src/cmd/insert.rs | 95 +---- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/smartart.rs | 603 +++++++++++++++++++++++++++ crates/engine/src/tests.rs | 49 +++ crates/model/src/lib.rs | 21 + crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/lib.rs | 14 +- crates/ui-egui/src/panes.rs | 95 +++++ crates/ui-egui/src/ribbon.rs | 51 +++ crates/ui-egui/src/smartart_tests.rs | 53 +++ docs/file-format-parity.md | 2 +- docs/gaps.md | 12 +- docs/parity-checklist.md | 6 +- docs/target-app-parity.md | 2 +- 14 files changed, 908 insertions(+), 98 deletions(-) create mode 100644 crates/engine/src/cmd/smartart.rs create mode 100644 crates/ui-egui/src/smartart_tests.rs diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..8efa7f2a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "SmartArt", ["Insert", "Illustrations"], None, - "{kind: list|process|cycle|hierarchy|pyramid|matrix, items: [text]}", + "{kind: list|process|cycle|hierarchy|pyramid|matrix, items?: [text] (leading tabs = sub-points), nodes?: [{text, level}]} → {id}", has_slide, smart_art ), @@ -709,92 +709,13 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { }) } -/// SmartArt-style graphics built from shapes (grouped), with our own layouts. +/// A SmartArt graphic: a group regenerated from its outline (see [`super::smartart`]). Items with +/// leading tabs are sub-points; `nodes` (`[{text, level}]`) may be given instead. fn smart_art(s: &mut Session, p: &Value) -> Result { let kind = str_param(p, "kind").unwrap_or("process").to_string(); - let items: Vec = - p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default(); - let items = if items.is_empty() { vec!["Text".to_string(); 3] } else { items }; - let n = items.len().clamp(1, 12); - let size = s.doc()?.doc.slide_size; - let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); - let slots = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; - let mut shapes: Vec<(String, Xfrm, usize)> = vec![]; - match kind.as_str() { - "cycle" => { - let r = area.h.min(area.w) * 0.38; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let d = r * 0.62; - for i in 0..n { - let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); - shapes.push(("ellipse".into(), Xfrm::new(cx + r * a.cos() - d / 2.0, cy + r * a.sin() - d / 2.0, d, d), i)); - } - } - "hierarchy" => { - let w = area.w / (n.max(2) - 1) as f64 * 0.8; - shapes.push(("roundRect".into(), Xfrm::new(area.x + area.w / 2.0 - w / 2.0, area.y, w, area.h * 0.3), 0)); - for i in 1..n { - let x = area.x + (i - 1) as f64 * area.w / (n - 1).max(1) as f64; - shapes.push(("roundRect".into(), Xfrm::new(x + w * 0.1, area.y + area.h * 0.55, w, area.h * 0.3), i)); - } - } - "pyramid" => { - let h = area.h / n as f64; - for i in 0..n { - let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64; - shapes.push(("trapezoid".into(), Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i)); - } - } - "matrix" => { - let (w, h) = (area.w / 2.0, area.h / 2.0); - for i in 0..n.min(4) { - shapes.push(( - "roundRect".into(), - Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0), - i, - )); - } - } - "list" => { - let h = area.h / n as f64; - for i in 0..n { - shapes.push(("roundRect".into(), Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i)); - } - } - _ => { - let gap = 24.0; - let w = (area.w - gap * (n as f64 - 1.0)) / n as f64; - for i in 0..n { - shapes.push(( - if i + 1 < n { "homePlate".into() } else { "roundRect".into() }, - Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), - i, - )); - } - } - } - let mut ids = vec![]; - for (preset, x, i) in shapes { - let mut sh = Shape { - xfrm: Some(x), - geom: Geom::preset(&preset), - style: Some(ShapeStyle::accent(slots.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1))), - ..Default::default() - }; - let mut body = TextBody::from_text(items.get(i).map(String::as_str).unwrap_or("")); - body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); - for para in &mut body.paragraphs { - para.props.align = Some(deckcraft_model::text::Align::Center); - for r in &mut para.runs { - r.props.size = Some(20.0); - } - } - sh.text = Some(body); - ids.push(add_shape(s, sh, false)?.0); - } - let g = s.execute("arrange.group", &json!({"ids": ids}))?; - if let Some(gid) = g.get("id").and_then(Value::as_u64) { - let _ = s.execute("shape.rename", &json!({"id": gid, "name": format!("SmartArt ({kind})")})); - } - Ok(g) + let nodes = match p.get("nodes") { + Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("insert.smartArt", e.to_string()))?, + None => super::smartart::nodes_from_lines(p.get("items").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str)), + }; + super::smartart::insert(s, &kind, nodes) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..e1b8eb4d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod review; pub mod selection; pub mod shape; pub mod slide; +pub mod smartart; pub mod table; pub mod text; pub mod transition; @@ -129,6 +130,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(slide::specs()); v.extend(insert::specs()); + v.extend(smartart::specs()); v.extend(shape::specs()); v.extend(arrange::specs()); v.extend(selection::specs()); diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs new file mode 100644 index 00000000..0993e87d --- /dev/null +++ b/crates/engine/src/cmd/smartart.rs @@ -0,0 +1,603 @@ +//! SmartArt: a diagram's outline (the Text Pane) and the shapes generated from it. +//! +//! A SmartArt graphic is a group whose `diagram` holds its layout and its nodes (text + outline +//! level). Every edit changes the nodes and regenerates the group's children inside the group's +//! child rectangle, so the diagram keeps its size and place. Text typed straight into a diagram +//! shape is read back into the nodes before each regeneration. + +use deckcraft_color::SchemeSlot; +use deckcraft_geom::Xfrm; +use deckcraft_model::text::{Align, AutoFit, Paragraph, TextBody}; +use deckcraft_model::{Diagram, DiagramNode, Fill, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle}; +use serde_json::{Value, json}; + +use super::*; +use crate::{EngineError, Result, Session}; + +/// Deepest outline level the Text Pane allows. +pub const MAX_LEVEL: u8 = 8; +/// Most top-level shapes a layout draws. +const MAX_SHAPES: usize = 40; + +pub const KINDS: [&str; 6] = ["list", "process", "cycle", "hierarchy", "pyramid", "matrix"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "smartArt.nodes", + "Text Pane", + ["SmartArt Design", "Create Graphic"], + None, + "{id?} → {id, kind, nodes: [{text, level}]} (id: the SmartArt group or one of its shapes; default the selection)", + has_selection, + nodes + ), + cmd!( + "smartArt.setNodes", + "Edit SmartArt Text", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, nodes?: [{text, level}], lines?: [string] (leading tabs set the level), index?, text?} (index + text edits one node)", + has_selection, + set_nodes + ), + cmd!( + "smartArt.addNode", + "Add Shape", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index?, position?: after|before|below|above, text?} → {index} (default: after the last node)", + has_selection, + add_node + ), + cmd!( + "smartArt.removeNode", + "Remove Shape", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index} (its sub-points move up one level)", + has_selection, + remove_node + ), + cmd!("smartArt.promote", "Promote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, promote), + cmd!("smartArt.demote", "Demote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, demote), + cmd!( + "smartArt.layout", + "Change Layout", + ["SmartArt Design", "Layouts"], + None, + "{id?, kind: list|process|cycle|hierarchy|pyramid|matrix}", + has_selection, + change_layout + ), + ] +} + +/// Nodes from text lines: each leading tab is one outline level. +pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec { + lines + .into_iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let tabs = l.chars().take_while(|c| *c == '\t').count(); + DiagramNode { text: l.trim().to_string(), level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL) } + }) + .collect() +} + +/// Levels made consistent: the first node is top level and no node is more than one level +/// deeper than the node before it. +pub fn normalize(nodes: &mut [DiagramNode]) { + let mut prev: Option = None; + for n in nodes.iter_mut() { + n.level = match prev { + None => 0, + Some(p) => n.level.min(p.saturating_add(1)).min(MAX_LEVEL), + }; + prev = Some(n.level); + } +} + +/// Index one past the last descendant of node `i`. +fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize { + let lv = nodes.get(i).map(|n| n.level).unwrap_or(0); + let mut j = i + 1; + while nodes.get(j).is_some_and(|n| n.level > lv) { + j += 1; + } + j +} + +/// Add a node next to node `index` (PowerPoint's Add Shape After/Before/Below/Above). Returns the +/// new node's index. +pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { + let Some(i) = index.filter(|i| *i < nodes.len()) else { + nodes.push(DiagramNode { text: text.into(), level: 0 }); + return nodes.len() - 1; + }; + let lv = nodes[i].level; + let (at, level) = match position { + "before" => (i, lv), + "below" => (subtree_end(nodes, i), (lv + 1).min(MAX_LEVEL)), + "above" => { + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level = (n.level + 1).min(MAX_LEVEL); + } + (i, lv) + } + _ => (subtree_end(nodes, i), lv), + }; + nodes.insert(at, DiagramNode { text: text.into(), level }); + at +} + +/// Remove node `i`; its sub-points move up one level. +pub fn remove(nodes: &mut Vec, i: usize) -> bool { + if i >= nodes.len() { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i + 1..end] { + n.level = n.level.saturating_sub(1); + } + nodes.remove(i); + normalize(nodes); + true +} + +/// Move node `i` and its sub-points one level up. False when it is already top level. +pub fn promote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + if nodes.get(i).is_none_or(|n| n.level == 0) { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level -= 1; + } + true +} + +/// Move node `i` and its sub-points one level down, under the node before it. False when there is +/// nothing to go under. +pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + let (Some(prev), Some(cur)) = (i.checked_sub(1).and_then(|p| nodes.get(p)), nodes.get(i)) else { return false }; + if cur.level > prev.level || cur.level >= MAX_LEVEL { + return false; + } + let end = subtree_end(nodes, i); + if nodes[i..end].iter().any(|n| n.level >= MAX_LEVEL) { + return false; + } + for n in &mut nodes[i..end] { + n.level += 1; + } + true +} + +/// Which nodes each generated text shape shows, in child order (lines drawn after these carry none). +fn node_map(d: &Diagram) -> Vec> { + if d.kind == "hierarchy" { + return (0..d.nodes.len().min(MAX_SHAPES)).map(|i| vec![i]).collect(); + } + let mut out: Vec> = vec![]; + for (i, n) in d.nodes.iter().enumerate() { + match out.last_mut() { + Some(g) if n.level > 0 => g.push(i), + _ => out.push(vec![i]), + } + } + let cap = if d.kind == "matrix" { 4 } else { MAX_SHAPES }; + out.truncate(cap); + out +} + +/// Read text typed into the diagram's shapes back into its nodes. +fn sync_text(d: &mut Diagram, children: &[Shape]) { + for (shape, idx) in children.iter().zip(node_map(d)) { + let Some(body) = &shape.text else { continue }; + if body.paragraphs.len() != idx.len() { + continue; + } + for (p, i) in body.paragraphs.iter().zip(idx) { + if let Some(n) = d.nodes.get_mut(i) { + n.text = p.text(); + } + } + } +} + +fn slot(i: usize) -> SchemeSlot { + [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] +} + +fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Shape { + let mut body = TextBody::default(); + body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); + let top = lines.first().map(|n| n.level).unwrap_or(0); + for (k, n) in lines.iter().enumerate() { + let mut para = Paragraph::new(&n.text); + if k == 0 { + para.props.align = Some(Align::Center); + } else { + para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL); + para.props.align = Some(Align::Left); + para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); + para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0)); + para.props.indent = Some(-10.0); + } + let size = if k == 0 { 20.0 } else { 14.0 }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); + body.paragraphs.push(para); + } + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::new("")); + } + Shape { + name: format!("SmartArt shape {}", color + 1), + xfrm: Some(x), + geom: Geom::preset(preset), + style: Some(ShapeStyle::accent(slot(color))), + text: Some(body), + ..Default::default() + } +} + +fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { + let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()); + x.flip_h = (b.0 < a.0) != (b.1 < a.1); + Shape { + name: "SmartArt connector".into(), + xfrm: Some(x), + geom: Geom::preset("line"), + style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), + fill: Some(Fill::None), + ..Default::default() + } +} + +/// The diagram's shapes laid out in `area` (ids not yet assigned). +pub fn layout(d: &Diagram, area: Xfrm) -> Vec { + let map = node_map(d); + let groups: Vec> = map.iter().map(|g| g.iter().filter_map(|i| d.nodes.get(*i)).collect()).collect(); + let n = groups.len().max(1); + let mut out = vec![]; + match d.kind.as_str() { + "hierarchy" => return hierarchy(d, area), + "cycle" => { + let r = area.h.min(area.w) * 0.38; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = r * 0.62; + for (i, g) in groups.iter().enumerate() { + let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); + out.push(text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), i, g)); + } + } + "pyramid" => { + let h = area.h / n as f64; + for (i, g) in groups.iter().enumerate() { + let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64; + out.push(text_shape("trapezoid", Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i, g)); + } + } + "matrix" => { + let (w, h) = (area.w / 2.0, area.h / 2.0); + for (i, g) in groups.iter().enumerate() { + let x = Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0); + out.push(text_shape("roundRect", x, i, g)); + } + } + "list" => { + let h = area.h / n as f64; + for (i, g) in groups.iter().enumerate() { + out.push(text_shape("roundRect", Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i, g)); + } + } + _ => { + let gap = 24.0; + let w = ((area.w - gap * (n as f64 - 1.0)) / n as f64).max(1.0); + for (i, g) in groups.iter().enumerate() { + let preset = if i + 1 < n { "homePlate" } else { "roundRect" }; + out.push(text_shape(preset, Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), i, g)); + } + } + } + out +} + +/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. +fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + let depth: Vec = (0..nodes.len()) + .map(|i| { + let (mut d, mut p) = (0, parent[i]); + while let Some(q) = p { + d += 1; + p = parent[q]; + } + d + }) + .collect(); + let rows = depth.iter().max().copied().unwrap_or(0) + 1; + let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); + let cell_w = area.w / total as f64; + let row_h = area.h / rows as f64; + let (bw, bh) = (cell_w * 0.85, row_h * 0.6); + // Left edge (in leaves) of each node's span. + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + Xfrm::new(cx - bw / 2.0, area.y + depth[i] as f64 * row_h + (row_h - bh) / 2.0, bw, bh) + }) + .collect(); + let mut out: Vec = nodes.iter().enumerate().map(|(i, n)| text_shape("roundRect", boxes[i], depth[i], &[n])).collect(); + for i in 0..nodes.len() { + if let Some(p) = parent[i] { + let (a, b) = (boxes[p], boxes[i]); + out.push(line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y))); + } + } + out +} + +/// The SmartArt group that `id` is, or is inside. +fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { + for sh in shapes { + if sh.id == id && sh.diagram.is_some() { + return Some(sh); + } + if let ShapeKind::Group { children, .. } = &sh.kind { + if sh.diagram.is_some() && deckcraft_model::find_shape(children, id).is_some() { + return Some(sh); + } + if let Some(g) = diagram_group(children, id) { + return Some(g); + } + } + } + None +} + +/// The SmartArt graphic the selection is (or is inside), with its outline as the Text Pane shows it. +pub fn selected(st: &crate::DocState) -> Option<(ShapeId, Diagram)> { + let shapes = current_shapes(&st.doc, &st.selection)?; + let g = st.selection.shapes.iter().find_map(|id| diagram_group(shapes, *id))?; + let mut d = g.diagram.clone()?; + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Some((g.id, d)) +} + +fn target_group(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?; + let ids = targets(s, p)?; + ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic")) +} + +/// Children with fresh ids for diagram `d` inside a group whose child space is `area`. +fn regenerate(doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Vec { + let mut v = layout(d, area); + for sh in &mut v { + sh.id = ShapeId(doc.alloc_id()); + } + v +} + +/// Read the diagram (with typed text synced), let `f` change it, and regenerate the group. +fn edit_diagram(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result) -> Result { + let gid = target_group(s, p, cmd)?; + s.edit(|doc, sel| { + let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?; + let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) }; + sync_text(&mut d, &children); + let out = f(&mut d)?; + normalize(&mut d.nodes); + let kids = regenerate(doc, &d, child); + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; + let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?; + sh.kind = ShapeKind::Group { children: kids, child }; + sh.diagram = Some(d); + sel.shapes = vec![gid]; + sel.text = None; + Ok(out) + }) +} + +fn index(p: &Value, cmd: &str) -> Result { + p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "index is required")) +} + +/// Insert a new SmartArt graphic (`insert.smartArt`). +pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { + let kind = if KINDS.contains(&kind) { kind } else { "process" }; + if nodes.is_empty() { + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect(); + } + normalize(&mut nodes); + let d = Diagram { kind: kind.into(), nodes }; + let size = s.doc()?.doc.slide_size; + let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); + let id = s.edit(|doc, sel| { + let children = regenerate(doc, &d, area); + let id = ShapeId(doc.alloc_id()); + let g = Shape { + id, + name: format!("SmartArt ({kind})"), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + diagram: Some(d), + ..Default::default() + }; + crate::shapes_mut(doc, sel).ok_or_else(|| EngineError::Other("no slide".into()))?.push(g); + sel.shapes = vec![id]; + sel.text = None; + Ok(id) + })?; + Ok(json!({"id": id.0})) +} + +fn nodes(s: &mut Session, p: &Value) -> Result { + let gid = target_group(s, p, "smartArt.nodes")?; + let g = shape_of(s, gid)?; + let mut d = g.diagram.unwrap_or_default(); + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Ok(json!({"id": gid.0, "kind": d.kind, "nodes": d.nodes})) +} + +fn set_nodes(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.setNodes"; + let given: Option> = match (p.get("nodes"), p.get("lines").and_then(Value::as_array)) { + (Some(v), _) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(CMD, e.to_string()))?), + (None, Some(a)) => Some(nodes_from_lines(a.iter().filter_map(Value::as_str))), + _ => None, + }; + let one = p.get("index").map(|_| index(p, CMD)).transpose()?; + let text = str_param(p, "text").map(String::from); + edit_diagram(s, p, CMD, |d| { + if let Some(n) = given { + d.nodes = n; + } + if let (Some(i), Some(t)) = (one, text) { + d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t; + } + Ok(Value::Null) + }) +} + +fn add_node(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()); + let pos = str_param(p, "position").unwrap_or("after").to_string(); + let text = str_param(p, "text").unwrap_or("").to_string(); + edit_diagram(s, p, "smartArt.addNode", |d| { + let at = add(&mut d.nodes, i, &pos, &text); + Ok(json!({"index": at})) + }) +} + +fn remove_node(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.removeNode"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if remove(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "no such node")) }) +} + +fn promote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.promote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if promote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "already at the top level")) }) +} + +fn demote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.demote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) +} + +fn change_layout(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.layout"; + let kind = str_param(p, "kind").filter(|k| KINDS.contains(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + d.kind = kind; + Ok(Value::Null) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lv(n: &[DiagramNode]) -> Vec { + n.iter().map(|x| x.level).collect() + } + fn mk(levels: &[u8]) -> Vec { + levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l }).collect() + } + + #[test] + fn outline_edits() { + let mut n = mk(&[0, 1, 1, 0]); + assert_eq!(add(&mut n, Some(0), "after", "x"), 3, "after the node's sub-points"); + assert_eq!(lv(&n), [0, 1, 1, 0, 0]); + assert_eq!(add(&mut n, Some(0), "below", "y"), 3); + assert_eq!(lv(&n), [0, 1, 1, 1, 0, 0]); + assert_eq!(add(&mut n, Some(0), "above", "z"), 0); + assert_eq!(lv(&n), [0, 1, 2, 2, 2, 0, 0]); + assert!(remove(&mut n, 1)); + assert_eq!(lv(&n), [0, 1, 1, 1, 0, 0]); + assert!(!promote_node(&mut n, 0)); + assert!(!demote_node(&mut n, 0), "nothing above the first node"); + assert!(!demote_node(&mut n, 2) || lv(&n)[2] == 2); + let mut m = mk(&[0, 0, 1]); + assert!(demote_node(&mut m, 1)); + assert_eq!(lv(&m), [0, 1, 2], "sub-points move with their node"); + assert!(promote_node(&mut m, 1)); + assert_eq!(lv(&m), [0, 0, 1]); + let mut bad = mk(&[2, 4, 0]); + normalize(&mut bad); + assert_eq!(lv(&bad), [0, 1, 0]); + assert_eq!(lv(&nodes_from_lines(["A", "\tB", "\t\tC", "", "D"])), [0, 1, 2, 0]); + } + + #[test] + fn layouts_map_nodes_to_shapes() { + let area = Xfrm::new(0.0, 0.0, 800.0, 400.0); + let d = Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]) }; + let v = layout(&d, area); + assert_eq!(v.len(), 2); + let t = v[0].text.as_ref().map(|b| b.paragraphs.len()); + assert_eq!(t, Some(3), "sub-points are bullets in their parent's shape"); + let h = Diagram { kind: "hierarchy".into(), nodes: mk(&[0, 1, 2, 1]) }; + let v = layout(&h, area); + assert_eq!(v.len(), 4 + 3, "a box per node and a line per parent link"); + for sh in &v { + let x = sh.xfrm.unwrap_or_default(); + assert!(x.x >= -0.01 && x.x + x.w <= 800.01 && x.y >= -0.01 && x.y + x.h <= 400.01, "{x:?}"); + } + assert!(layout(&Diagram { kind: "cycle".into(), nodes: vec![] }, area).is_empty()); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..9d725f88 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,52 @@ 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 smartart_text_pane_edits_regenerate_the_diagram() { + let mut s = session(); + let r = s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "\tScope", "Build"]})).unwrap(); + let gid = r["id"].as_u64().unwrap(); + let kids = |s: &Session| match s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).map(|x| &x.kind) { + Some(deckcraft_model::ShapeKind::Group { children, .. }) => children.clone(), + _ => panic!("not a group"), + }; + assert_eq!(kids(&s).len(), 2, "Scope is a bullet inside Plan"); + let box_before = s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).unwrap().xfrm; + + // Type into the second shape directly, then add a node: the typed text is kept. + let build = kids(&s)[1].id.0; + s.execute("text.edit", &json!({"id": build, "end": true})).unwrap(); + s.execute("text.insert", &json!({"text": " it"})).unwrap(); + let r = s.execute("smartArt.addNode", &json!({"id": gid, "index": 2, "position": "after", "text": "Ship"})).unwrap(); + assert_eq!(r["index"], 3); + let n = s.execute("smartArt.nodes", &json!({"id": gid})).unwrap(); + let texts: Vec<&str> = n["nodes"].as_array().unwrap().iter().map(|x| x["text"].as_str().unwrap()).collect(); + assert_eq!(texts, ["Plan", "Scope", "Build it", "Ship"]); + assert_eq!(kids(&s).len(), 3); + + // Demote Ship under Build, promote Scope, remove Plan. + s.execute("smartArt.demote", &json!({"id": gid, "index": 3})).unwrap(); + assert_eq!(kids(&s).len(), 2); + s.execute("smartArt.promote", &json!({"id": gid, "index": 1})).unwrap(); + assert_eq!(kids(&s).len(), 3); + assert!(s.execute("smartArt.promote", &json!({"id": gid, "index": 0})).is_err()); + s.execute("smartArt.removeNode", &json!({"id": gid, "index": 0})).unwrap(); + let n = s.execute("smartArt.nodes", &json!({"id": gid})).unwrap(); + let lv: Vec = n["nodes"].as_array().unwrap().iter().map(|x| x["level"].as_u64().unwrap()).collect(); + assert_eq!(lv, [0, 0, 1]); + + // A child shape also finds its diagram; layouts switch; the box stays put; undo works. + let child = kids(&s)[0].id.0; + s.execute("smartArt.layout", &json!({"id": child, "kind": "hierarchy"})).unwrap(); + assert_eq!(kids(&s).len(), 3 + 1, "three boxes and one link"); + assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).unwrap().xfrm, box_before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(kids(&s).len(), 2); + + // The outline survives a .deckcraft save. + let doc = s.doc().unwrap().doc.as_ref().clone(); + let bytes = deckcraft_format::save(&doc).unwrap(); + let back = deckcraft_format::load(&bytes).unwrap(); + assert!(back.slides[0].shapes.iter().any(|x| x.diagram.as_ref().is_some_and(|d| d.nodes.len() == 3))); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..bdd52a63 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -320,6 +320,26 @@ pub struct Shape { /// Unmodelled XML kept for round-trip (extension lists etc.). #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, + /// On a SmartArt group: the diagram's text outline, from which its shapes are regenerated. + #[serde(skip_serializing_if = "Option::is_none")] + pub diagram: Option, +} + +/// A SmartArt diagram's data: its layout and its outline of nodes (the Text Pane). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Diagram { + /// list, process, cycle, hierarchy, pyramid or matrix. + pub kind: String, + pub nodes: Vec, +} + +/// One line of the Text Pane: its text and outline level (0 = top level). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DiagramNode { + pub text: String, + pub level: u8, } impl Default for Shape { @@ -345,6 +365,7 @@ impl Default for Shape { click: None, hover: None, raw_ext: None, + diagram: None, } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..2c685540 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -683,6 +683,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, "Insert"); if ok { run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); + app.ui.pane = Some("smartart".into()); } ok || cancel } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..6b2f0e33 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,7 +86,8 @@ pub struct UiState { pub notes: bool, pub notes_height: f32, pub thumbs_width: f32, - /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`. + /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`, + /// `smartart` (Text Pane). pub pane: Option, pub pane_width: f32, pub ruler: bool, @@ -101,6 +102,12 @@ pub struct UiState { pub ui_scale: f32, /// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`). pub format_tab: String, + /// The Text Pane's current SmartArt line (Add Shape, Promote and Demote act on it). + pub smartart_row: usize, + /// A Text Pane line to focus once it is drawn (after Enter, Tab or Backspace rebuilt the list). + pub smartart_focus: Option, + /// The Text Pane's line drafts, for the SmartArt group they were read from. + pub smartart_drafts: (u32, Vec), pub grayscale: bool, } @@ -126,6 +133,9 @@ impl Default for UiState { presenter_view: true, ui_scale: 1.0, format_tab: "fill".into(), + smartart_row: 0, + smartart_focus: None, + smartart_drafts: (0, vec![]), grayscale: false, } } @@ -146,7 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.zoom", "Zoom…", None, "{percent}"), ("view.fit", "Fit to Window", None, "{}"), - ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|null}"), + ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|smartart|null}"), ("view.tab", "Ribbon Tab", None, "{tab}"), ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"), ("view.dark", "Dark Mode", None, "{on?: bool}"), diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..5088dc0b 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -27,6 +27,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => "Selection", "comments" => "Comments", "designer" => "Design Ideas", + "smartart" => "Text Pane", _ => "Pane", }; ui.horizontal(|ui| { @@ -50,11 +51,101 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), + "smartart" => smartart(app, ui), _ => {} }); }); } +/// SmartArt Text Pane: one line per node, indented by level. Enter adds a line after, Tab and +/// Shift+Tab demote and promote, Backspace in an empty line removes it. +fn smartart(app: &mut SlideApp, ui: &mut Ui) { + let Some((gid, d)) = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected) else { + ui.label("Select a SmartArt graphic to edit its text."); + return; + }; + let texts: Vec = d.nodes.iter().map(|n| n.text.clone()).collect(); + let focused_any = ui.ctx().memory(|m| m.focused().is_some()) || app.ui.smartart_focus.is_some(); + if app.ui.smartart_drafts.0 != gid.0 || app.ui.smartart_drafts.1.len() != texts.len() || !focused_any { + app.ui.smartart_drafts = (gid.0, texts.clone()); + } + let mut drafts = std::mem::take(&mut app.ui.smartart_drafts.1); + let mut action: Option<(&str, Value)> = None; + let mut commit: Option = None; + let mut focus_row: Option = None; + ui.label(egui::RichText::new("Type your text here").color(Tokens::get(ui.ctx()).text_dim)); + ui.add_space(4.0); + for (i, node) in d.nodes.iter().enumerate() { + let Some(draft) = drafts.get_mut(i) else { break }; + let id = egui::Id::new(("smartart_line", gid.0, i)); + ui.horizontal(|ui| { + ui.add_space(16.0 * node.level as f32); + ui.label("•"); + let te = egui::TextEdit::singleline(draft).id(id).desired_width(f32::INFINITY); + let r = ui.add(te); + if app.ui.smartart_focus == Some(i) { + r.request_focus(); + app.ui.smartart_focus = None; + } + if r.gained_focus() || r.has_focus() { + app.ui.smartart_row = i; + } + if r.has_focus() { + let back = ui.input(|inp| inp.key_pressed(egui::Key::Backspace)); + if back && draft.is_empty() && texts.get(i).is_some_and(String::is_empty) && d.nodes.len() > 1 { + action = Some(("smartArt.removeNode", json!({"index": i}))); + focus_row = Some(i.saturating_sub(1)); + } + } + if r.lost_focus() { + let (tab, shift) = ui.input(|inp| (inp.key_pressed(egui::Key::Tab), inp.modifiers.shift)); + if tab { + action = Some((if shift { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": i}))); + focus_row = Some(i); + } else if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { + action = Some(("smartArt.addNode", json!({"index": i, "position": "after"}))); + focus_row = Some(i + 1); + } + commit = Some(i); + } + }); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("+ Add").clicked() { + action = Some(("smartArt.addNode", json!({"index": app.ui.smartart_row.min(d.nodes.len().saturating_sub(1)), "position": "after"}))); + } + if ui.button("Remove").clicked() && !d.nodes.is_empty() { + action = Some(("smartArt.removeNode", json!({"index": app.ui.smartart_row.min(d.nodes.len() - 1)}))); + } + }); + // Commit a changed line before any structural edit, so the edit sees the new text. + if let Some(i) = commit + && drafts.get(i) != texts.get(i) + { + run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": drafts.get(i).cloned().unwrap_or_default()})); + } + if action.is_some() { + for (i, t) in drafts.iter().enumerate() { + if Some(i) != commit && texts.get(i) != Some(t) { + run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": t})); + } + } + } + app.ui.smartart_drafts.1 = drafts; + if let Some((cmd, mut p)) = action { + if let Some(o) = p.as_object_mut() { + o.insert("id".into(), json!(gid.0)); + } + if let Ok(r) = app.run(cmd, p) { + let row = r.get("index").and_then(Value::as_u64).map(|v| v as usize).or(focus_row).unwrap_or(0); + app.ui.smartart_row = row; + app.ui.smartart_drafts.0 = 0; + app.ui.smartart_focus = Some(row); + } + } +} + fn run(app: &mut SlideApp, id: &str, p: Value) { let _ = app.run(id, p); } @@ -635,3 +726,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(8.0); } } + +#[cfg(test)] +#[path = "smartart_tests.rs"] +mod smartart_tests; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..c4c31c81 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -122,6 +122,9 @@ fn contextual(app: &SlideApp) -> Vec<&'static str> { v.push("Table Design"); v.push("Layout"); } + if deckcraft_engine::cmd::smartart::selected(st).is_some() { + v.push("SmartArt Design"); + } if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Chart(_))) { v.push("Chart Design"); } @@ -220,6 +223,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "Table Design" => table_design(app, ui), "Layout" => table_layout(app, ui), "Chart Design" => chart_design(app, ui), + "SmartArt Design" => smartart_design(app, ui), "Playback" => playback(app, ui), "Slide Master" => slide_master(app, ui), _ => {} @@ -2318,6 +2322,53 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { arrange_group(app, ui); } +/// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts. +fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { + let sel = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected); + let row = app.ui.smartart_row; + let (gid, n, kind) = match &sel { + Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone()), + None => (None, 0, String::new()), + }; + let at = row.min(n.saturating_sub(1)); + group(ui, |ui| { + if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() { + run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); + } + rows(ui, |ui| { + let (r, _) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + for (label, pos) in + [("Add Shape After", "after"), ("Add Shape Before", "before"), ("Add Shape Above", "above"), ("Add Shape Below", "below")] + { + if ui.button(label).clicked() { + let index = if n == 0 { Value::Null } else { json!(at) }; + run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); + } + } + }); + if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() { + run(app, "smartArt.promote", json!({"id": gid, "index": at})); + } + if small_button(ui, Icon::IncreaseIndent, "Demote", n > 0, false).clicked() { + run(app, "smartArt.demote", json!({"id": gid, "index": at})); + } + }); + }); + group(ui, |ui| { + let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + for k in deckcraft_engine::cmd::smartart::KINDS { + let label = format!("{}{}", k[..1].to_uppercase(), &k[1..]); + if ui.selectable_label(kind == k, label).clicked() { + run(app, "smartArt.layout", json!({"id": gid, "kind": k})); + } + } + }); + }); + arrange_group(app, ui); +} + fn playback(app: &mut SlideApp, ui: &mut Ui) { let m = app .session diff --git a/crates/ui-egui/src/smartart_tests.rs b/crates/ui-egui/src/smartart_tests.rs new file mode 100644 index 00000000..7692f704 --- /dev/null +++ b/crates/ui-egui/src/smartart_tests.rs @@ -0,0 +1,53 @@ +//! The SmartArt Text Pane driven like a person would: type in a line, Enter for a new line, Tab +//! to demote. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn levels(h: &Harness<'_, SlideApp>) -> Vec<(String, u8)> { + let st = h.state().session.active().unwrap(); + let (_, d) = deckcraft_engine::cmd::smartart::selected(st).unwrap(); + d.nodes.into_iter().map(|n| (n.text, n.level)).collect() +} + +fn line<'a>(h: &'a Harness<'_, SlideApp>, v: &'a str) -> egui_kittest::Node<'a> { + h.get_all_by_value(v).find(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput).unwrap() +} + +#[test] +fn text_pane_types_adds_and_demotes() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "Build"]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("smartart".into()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_label("Text Pane").is_some()); + + line(&h, "Build").click(); + h.run_steps(2); + h.key_press(egui::Key::End); + line(&h, "Build").type_text(" it"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), (String::new(), 0)]); + + // The new line has the focus: type, then Tab demotes it under "Build it". + h.event(egui::Event::Text("Test".into())); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), ("Test".into(), 1)]); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..d1427027 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -74,7 +74,7 @@ the round trip even though DeckCraft doesn't model it. | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | -| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | +| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save (DeckCraft keeps its own outline in `.deckcraft` only) | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | | Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..b737232c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -150,11 +150,15 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). -### SmartArt is pre-drawn shapes only -- **Evidence:** `crates/pptx/src/read/shapes.rs` reads SmartArt's drawing part as a group; no text - pane, no layout switching, the diagram data isn't regenerated. +### SmartArt editing is shallow +- **Evidence:** a SmartArt graphic inserted in DeckCraft keeps its outline (`Shape.diagram`) and + has a Text Pane, Add Shape (after, before, above, below), Promote, Demote, Remove and layout + switching between DeckCraft's six layouts (`crates/engine/src/cmd/smartart.rs`). But + `crates/pptx/src/read/shapes.rs` reads a PowerPoint diagram's drawing part as a plain group (no + outline, so no Text Pane), the outline isn't written to `.pptx` (`dgm` data), and there are six + layouts rather than PowerPoint's ~200. - **Impact:** editing a SmartArt diagram from PowerPoint breaks it. -- **Estimate:** 10–16 h. +- **Estimate:** 8–14 h. ### Animation Pane, effect count and preview Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..e251efae 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 13 | 16 | 25 | 39% | | Area | Parity | |---|---| @@ -24,7 +24,7 @@ The summary between ` | Text | 80% | | Tables | 89% | | Charts | 65% | -| SmartArt | 33% | +| SmartArt | 50% | | Pictures | 48% | | Media | 67% | | Transitions | 92% | @@ -177,7 +177,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Charts | Chart elements (title, legend, labels, axes, gridlines) | P1 | P | | Charts | Chart styles and colours | P2 | P | | SmartArt | Insert SmartArt (lists, processes, cycles, hierarchy) | P1 | P | -| SmartArt | Text pane and layout switching | P2 | M | +| SmartArt | Text pane and layout switching | P2 | P | | Pictures | Insert picture (PNG, JPEG, GIF, WebP, BMP, TIFF) | P0 | D | | Pictures | SVG pictures | P1 | M | | Pictures | Crop (and crop to shape, aspect ratio) | P0 | P | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..0c96188c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -190,7 +190,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) | | Tables | 5 | 89% | ~75% | 3–6 | Draw table, borders and cell effects depth | [parity-checklist.md](parity-checklist.md) | | Charts | 7 | 65% | ~45% | 12–20 | Data grid editing, chart elements, the `chartex` types (waterfall, funnel, treemap, sunburst, histogram, box) | [parity-checklist.md](parity-checklist.md) | -| SmartArt | 4 | 33% | ~25% | 10–16 | Text pane, layout switching, the diagram data model on save | [parity-checklist.md](parity-checklist.md) | +| SmartArt | 4 | 50% | ~35% | 8–14 | PowerPoint diagrams editable (read their data model), the data model on save, more layouts | [parity-checklist.md](parity-checklist.md) | | Pictures | 7 | 48% | ~45% | 12–20 | SVG/EMF/WMF, clipboard paste on Windows/Linux (#53), artistic effects, remove background, compress | [file-format-parity.md](file-format-parity.md) | | Media | 4 | 67% | ~60% | 8–14 | WMA/WMV decode, record audio/screen, icons and 3-D models, playback options lost on PPTX round trip (#74) | [file-format-parity.md](file-format-parity.md) | | Transitions | 4 | 92% | ~80% | 4–8 | Transition sounds, 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) | From b03b19bca73f6e0d0d14c2e5db8683c4219547fd Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:29:49 +0300 Subject: [PATCH 03/17] SmartArt: write the outline to .pptx as a diagram; read it back from the data model Export: a group with a diagram becomes a graphicFrame naming ppt/diagrams data, layout, quickStyle and colors parts, plus the group's shapes as the cached drawing (dsp). The data model is built from the outline (doc, node, parTrans/sibTrans points and parOf links). A diagram read from a file keeps its own layout, quick style and colour parts until its layout is switched. Import: the outline is rebuilt from the data model's parOf tree, so PowerPoint diagrams get the Text Pane. Their shapes stay the file's drawing until the first outline edit (typed text isn't mapped back to nodes then). Co-authored-by: Cursor --- Cargo.lock | 1 + crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/smartart.rs | 17 +- crates/engine/src/tests.rs | 38 +++ crates/model/src/lib.rs | 15 + crates/pptx/src/dgm.rs | 451 ++++++++++++++++++++++++++++++ crates/pptx/src/lib.rs | 1 + crates/pptx/src/read/shapes.rs | 40 +++ crates/pptx/src/write/mod.rs | 7 + crates/pptx/src/write/shapes.rs | 99 ++++++- docs/file-format-parity.md | 2 +- docs/gaps.md | 18 +- 12 files changed, 675 insertions(+), 17 deletions(-) create mode 100644 crates/pptx/src/dgm.rs diff --git a/Cargo.lock b/Cargo.lock index 4044fce6..d089e421 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -955,6 +955,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1f4dbfcd..38794151 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,3 +25,6 @@ i_overlay = { workspace = true } thiserror = { workspace = true } log = { workspace = true } image = { workspace = true } + +[dev-dependencies] +zip = { workspace = true } diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 0993e87d..cfca7620 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -194,6 +194,9 @@ fn node_map(d: &Diagram) -> Vec> { /// Read text typed into the diagram's shapes back into its nodes. fn sync_text(d: &mut Diagram, children: &[Shape]) { + if d.imported { + return; + } for (shape, idx) in children.iter().zip(node_map(d)) { let Some(body) = &shape.text else { continue }; if body.paragraphs.len() != idx.len() { @@ -436,6 +439,7 @@ fn edit_diagram(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Di sync_text(&mut d, &children); let out = f(&mut d)?; normalize(&mut d.nodes); + d.imported = false; let kids = regenerate(doc, &d, child); let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?; @@ -458,7 +462,7 @@ pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Resul nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect(); } normalize(&mut nodes); - let d = Diagram { kind: kind.into(), nodes }; + let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; let size = s.doc()?.doc.slide_size; let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); let id = s.edit(|doc, sel| { @@ -542,6 +546,11 @@ fn change_layout(s: &mut Session, p: &Value) -> Result { const CMD: &str = "smartArt.layout"; let kind = str_param(p, "kind").filter(|k| KINDS.contains(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); edit_diagram(s, p, CMD, |d| { + if d.kind != kind { + // PowerPoint's own layout no longer applies. + d.layout_id.clear(); + d.layout_xml = None; + } d.kind = kind; Ok(Value::Null) }) @@ -586,18 +595,18 @@ mod tests { #[test] fn layouts_map_nodes_to_shapes() { let area = Xfrm::new(0.0, 0.0, 800.0, 400.0); - let d = Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]) }; + let d = Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }; let v = layout(&d, area); assert_eq!(v.len(), 2); let t = v[0].text.as_ref().map(|b| b.paragraphs.len()); assert_eq!(t, Some(3), "sub-points are bullets in their parent's shape"); - let h = Diagram { kind: "hierarchy".into(), nodes: mk(&[0, 1, 2, 1]) }; + let h = Diagram { kind: "hierarchy".into(), nodes: mk(&[0, 1, 2, 1]), ..Default::default() }; let v = layout(&h, area); assert_eq!(v.len(), 4 + 3, "a box per node and a line per parent link"); for sh in &v { let x = sh.xfrm.unwrap_or_default(); assert!(x.x >= -0.01 && x.x + x.w <= 800.01 && x.y >= -0.01 && x.y + x.h <= 400.01, "{x:?}"); } - assert!(layout(&Diagram { kind: "cycle".into(), nodes: vec![] }, area).is_empty()); + assert!(layout(&Diagram { kind: "cycle".into(), ..Default::default() }, area).is_empty()); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9d725f88..179b6a4f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -884,3 +884,41 @@ fn smartart_text_pane_edits_regenerate_the_diagram() { let back = deckcraft_format::load(&bytes).unwrap(); assert!(back.slides[0].shapes.iter().any(|x| x.diagram.as_ref().is_some_and(|d| d.nodes.len() == 3))); } + +#[test] +fn smartart_round_trips_through_pptx_as_a_diagram() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "hierarchy", "items": ["CEO", "\tCTO", "\t\tDev", "\tCFO"]})).unwrap(); + let doc = s.doc().unwrap().doc.as_ref().clone(); + let bytes = deckcraft_pptx::export(&doc).unwrap(); + let names: Vec = { + let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + z.file_names().map(String::from).collect() + }; + for part in ["data1", "layout1", "quickStyle1", "colors1", "drawing1"] { + assert!(names.iter().any(|n| n == &format!("ppt/diagrams/{part}.xml")), "{part} in {names:?}"); + } + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert!(back.validate().is_empty()); + let g = back.slides[1].shapes.iter().find(|x| x.diagram.is_some()).expect("a SmartArt group"); + let d = g.diagram.as_ref().unwrap(); + assert_eq!(d.kind, "hierarchy"); + assert!(d.imported); + let outline: Vec<(&str, u8)> = d.nodes.iter().map(|n| (n.text.as_str(), n.level)).collect(); + assert_eq!(outline, [("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1)]); + assert!(g.children().len() >= 4, "the cached drawing is read back as shapes"); + + // The Text Pane works on the imported diagram, and a second round trip keeps the edit. + let gid = g.id.0; + let path = std::env::temp_dir().join(format!("deckcraft-smartart-{}.pptx", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let mut s2 = Session::new(); + s2.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_file(&path); + s2.execute("slide.go", &json!({"index": 1})).unwrap(); + s2.execute("smartArt.addNode", &json!({"id": gid, "index": 3, "position": "after", "text": "COO"})).unwrap(); + let again = deckcraft_pptx::import(&deckcraft_pptx::export(s2.doc().unwrap().doc.as_ref()).unwrap()).unwrap(); + let d = again.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); + assert_eq!(d.nodes.last().map(|n| (n.text.as_str(), n.level)), Some(("COO", 1))); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index bdd52a63..377f94ef 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -332,6 +332,21 @@ pub struct Diagram { /// list, process, cycle, hierarchy, pyramid or matrix. pub kind: String, pub nodes: Vec, + /// The group's shapes are the file's drawing, not generated from `nodes`: text typed into them + /// isn't mapped back to nodes (the first outline edit regenerates them). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub imported: bool, + /// PowerPoint's layout id (`layoutDef@uniqueId`) the diagram came with. + #[serde(skip_serializing_if = "String::is_empty")] + pub layout_id: String, + /// The file's own layout, quick style and colour definitions, written back unchanged while the + /// layout isn't switched. + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_xml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub style_xml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub colors_xml: Option, } /// One line of the Text Pane: its text and outline level (0 = top level). diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs new file mode 100644 index 00000000..a3af2702 --- /dev/null +++ b/crates/pptx/src/dgm.rs @@ -0,0 +1,451 @@ +//! SmartArt (DrawingML diagrams): the outline ⇄ the data model, and the layout, quick style and +//! colour definitions DeckCraft writes for its own layouts. +//! +//! A diagram's data part holds its points (`dgm:pt`: the document, one per node, and the +//! transitions PowerPoint pairs with each link) and its links (`dgm:cxn` of type `parOf`, parent → +//! child, ordered by `srcOrd`). The outline is that tree flattened depth first. + +use std::collections::HashMap; + +use deckcraft_model::{Diagram, DiagramNode}; + +use crate::xml::{A, El, W}; + +pub const NS_DGM: &str = "http://schemas.openxmlformats.org/drawingml/2006/diagram"; +pub const NS_DSP: &str = "http://schemas.microsoft.com/office/drawing/2008/diagram"; +pub const CT_DATA: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml"; +pub const CT_LAYOUT: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml"; +pub const CT_STYLE: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml"; +pub const CT_COLORS: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml"; +pub const CT_DRAWING: &str = "application/vnd.ms-office.drawingml.diagramDrawing+xml"; +pub const RT_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing"; +const QS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/simple1"; +const CS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/colors/accent1_2"; +/// Most nodes read from one diagram. +const MAX_NODES: usize = 2000; + +/// PowerPoint's built-in layout DeckCraft's layout `kind` is written as. +pub fn layout_id(kind: &str) -> &'static str { + match kind { + "process" => "urn:microsoft.com/office/officeart/2005/8/layout/process1", + "cycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle2", + "hierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy1", + "pyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid1", + "matrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix3", + _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", + } +} + +/// The DeckCraft layout closest to a PowerPoint layout (by its id and category). +pub fn kind_for(layout_id: &str, category: &str) -> &'static str { + let s = format!("{layout_id} {category}").to_ascii_lowercase(); + if s.contains("hierarchy") || s.contains("orgchart") { + "hierarchy" + } else if s.contains("cycle") || s.contains("radial") { + "cycle" + } else if s.contains("pyramid") { + "pyramid" + } else if s.contains("matrix") { + "matrix" + } else if s.contains("process") || s.contains("chevron") || s.contains("arrow") { + "process" + } else { + "list" + } +} + +fn category(kind: &str) -> &'static str { + match kind { + "process" => "process", + "cycle" => "cycle", + "hierarchy" => "hierarchy", + "pyramid" => "pyramid", + "matrix" => "matrix", + _ => "list", + } +} + +/// A model id, stable for a given diagram (`seed`) and point number. +fn guid(seed: u32, n: usize) -> String { + format!("{{{seed:08X}-D1A6-4C0A-9E5B-{n:012X}}}") +} + +fn text(w: &mut W, t: &str) { + w.open0("dgm:t"); + w.empty0("a:bodyPr"); + w.empty0("a:lstStyle"); + w.open0("a:p"); + if t.is_empty() { + w.empty("a:endParaRPr", A::new().a("lang", "en-US")); + } else { + w.open0("a:r"); + w.empty("a:rPr", A::new().a("lang", "en-US")); + w.elt("a:t", t); + w.close("a:r"); + } + w.close("a:p"); + w.close("dgm:t"); +} + +/// `ppt/diagrams/dataN.xml` for the outline. `drawing_rel` is the slide's relationship id of the +/// cached drawing. +pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec { + let lo = if d.layout_id.is_empty() { layout_id(&d.kind).to_string() } else { d.layout_id.clone() }; + let mut w = W::new(); + w.open("dgm:dataModel", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R)); + w.open0("dgm:ptLst"); + let doc = guid(seed, 0); + w.open("dgm:pt", A::new().a("modelId", &doc).a("type", "doc")); + w.empty( + "dgm:prSet", + A::new() + .a("loTypeId", &lo) + .a("loCatId", category(&d.kind)) + .a("qsTypeId", QS_ID) + .a("qsCatId", "simple") + .a("csTypeId", CS_ID) + .a("csCatId", "accent1") + .a("phldr", "1"), + ); + w.empty0("dgm:spPr"); + text(&mut w, ""); + w.close("dgm:pt"); + // Node k: model id 1 + 3k; its link's parent and sibling transitions 2 + 3k and 3 + 3k. + let node = |k: usize| guid(seed, 1 + 3 * k); + for (k, n) in d.nodes.iter().enumerate() { + w.open("dgm:pt", A::new().a("modelId", node(k))); + w.empty("dgm:prSet", A::new().a("phldrT", "[Text]")); + w.empty0("dgm:spPr"); + text(&mut w, &n.text); + w.close("dgm:pt"); + let cxn = guid(seed, 0x10_0000 + k); + for (i, ty) in [(2, "parTrans"), (3, "sibTrans")] { + w.open("dgm:pt", A::new().a("modelId", guid(seed, i + 3 * k)).a("type", ty).a("cxnId", &cxn)); + w.empty0("dgm:prSet"); + w.empty0("dgm:spPr"); + text(&mut w, ""); + w.close("dgm:pt"); + } + } + w.close("dgm:ptLst"); + w.open0("dgm:cxnLst"); + let mut stack: Vec<(u8, String)> = vec![]; + let mut ords: HashMap = HashMap::new(); + for (k, n) in d.nodes.iter().enumerate() { + while stack.last().is_some_and(|(l, _)| *l >= n.level) { + stack.pop(); + } + let parent = stack.last().map(|(_, id)| id.clone()).unwrap_or_else(|| doc.clone()); + let ord = ords.entry(parent.clone()).or_insert(0); + w.empty( + "dgm:cxn", + A::new() + .a("modelId", guid(seed, 0x10_0000 + k)) + .a("srcId", &parent) + .a("destId", node(k)) + .a("srcOrd", *ord) + .a("destOrd", 0) + .a("parTransId", guid(seed, 2 + 3 * k)) + .a("sibTransId", guid(seed, 3 + 3 * k)), + ); + *ord += 1; + stack.push((n.level, node(k))); + } + w.close("dgm:cxnLst"); + w.empty0("dgm:bg"); + w.empty0("dgm:whole"); + w.open0("dgm:extLst"); + w.open("a:ext", A::new().a("uri", "http://schemas.microsoft.com/office/drawing/2008/diagram")); + w.empty( + "dsp:dataModelExt", + A::new().a("xmlns:dsp", NS_DSP).a("relId", drawing_rel).a("minVer", "http://schemas.openxmlformats.org/drawingml/2006/diagram"), + ); + w.close("a:ext"); + w.close("dgm:extLst"); + w.close("dgm:dataModel"); + w.finish() +} + +/// The outline of a data part: the `parOf` tree under the document point, depth first. +pub fn outline(data: &El) -> Vec { + let Some(pts) = data.child("ptLst") else { return vec![] }; + let mut texts: HashMap<&str, String> = HashMap::new(); + let mut doc: Option<&str> = None; + for pt in pts.children_named("pt").take(MAX_NODES * 4) { + let Some(id) = pt.attr("modelId") else { continue }; + match pt.attr("type").unwrap_or("node") { + "doc" => doc = doc.or(Some(id)), + "node" => { + let t = pt.child("t").map(paragraphs).unwrap_or_default(); + texts.insert(id, t); + } + _ => {} + } + } + let Some(doc) = doc else { return vec![] }; + let mut kids: HashMap<&str, Vec<(i64, &str)>> = HashMap::new(); + if let Some(cx) = data.child("cxnLst") { + for c in cx.children_named("cxn").take(MAX_NODES * 4) { + if c.attr("type").unwrap_or("parOf") != "parOf" { + continue; + } + let (Some(src), Some(dst)) = (c.attr("srcId"), c.attr("destId")) else { continue }; + if texts.contains_key(dst) { + kids.entry(src).or_default().push((c.i64("srcOrd").unwrap_or(0), dst)); + } + } + } + for v in kids.values_mut() { + v.sort_by_key(|(o, _)| *o); + } + let mut out = vec![]; + let mut seen = std::collections::HashSet::new(); + // Depth first, iteratively; a node reached twice (a cycle in a hostile file) is skipped. + let mut stack: Vec<(&str, u8)> = kids.get(doc).map(|v| v.iter().rev().map(|(_, id)| (*id, 0u8)).collect()).unwrap_or_default(); + while let Some((id, level)) = stack.pop() { + if out.len() >= MAX_NODES || !seen.insert(id) { + continue; + } + out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8) }); + if let Some(v) = kids.get(id) { + stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); + } + } + out +} + +/// A text body's paragraphs joined by spaces. +fn paragraphs(t: &El) -> String { + t.children_named("p") + .map(|p| { + let mut v = vec![]; + p.find_all("t", &mut v); + v.iter().map(|e| e.text()).collect::() + }) + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(" ") +} + +/// The layout algorithm and node shape of a DeckCraft layout. +fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static str) { + match kind { + "process" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), + "cycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), + "pyramid" => ("pyra", vec![("linDir", "fromT"), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid"), + "matrix" => ("snake", vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], "roundRect"), + _ => ("lin", vec![("linDir", "fromT"), ("vertAlign", "mid")], "roundRect"), + } +} + +fn node_layout(w: &mut W, shape: &str, bullets: bool) { + w.open("dgm:layoutNode", A::new().a("name", "node")); + w.open0("dgm:varLst"); + w.empty("dgm:bulletEnabled", A::new().a("val", if bullets { "1" } else { "0" })); + w.close("dgm:varLst"); + w.empty("dgm:alg", A::new().a("type", "tx")); + w.open("dgm:shape", A::new().a("type", shape).a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty("dgm:presOf", A::new().a("axis", if bullets { "desOrSelf" } else { "self" }).a("ptType", "node")); + w.open0("dgm:constrLst"); + for side in ["tMarg", "bMarg", "lMarg", "rMarg"] { + w.empty("dgm:constr", A::new().a("type", side).a("refType", "primFontSz").a("fact", "0.3")); + } + w.close("dgm:constrLst"); + w.open0("dgm:ruleLst"); + w.empty("dgm:rule", A::new().a("type", "primFontSz").a("val", "5").a("fact", "NaN").a("max", "NaN")); + w.close("dgm:ruleLst"); + w.close("dgm:layoutNode"); +} + +fn space(w: &mut W) { + w.open("dgm:forEach", A::new().a("name", "sibling").a("axis", "followSib").a("ptType", "sibTrans").a("cnt", "1")); + w.open("dgm:layoutNode", A::new().a("name", "space")); + w.empty("dgm:alg", A::new().a("type", "sp")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.close("dgm:layoutNode"); + w.close("dgm:forEach"); +} + +/// `ppt/diagrams/layoutN.xml`: a layout definition for DeckCraft's layout `kind`, under the id of +/// the closest built-in PowerPoint layout. +pub fn layout_xml(kind: &str) -> Vec { + let mut w = W::new(); + w.open( + "dgm:layoutDef", + A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R).a("uniqueId", layout_id(kind)), + ); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + w.open0("dgm:catLst"); + w.empty("dgm:cat", A::new().a("type", category(kind)).a("pri", "1000")); + w.close("dgm:catLst"); + w.open("dgm:layoutNode", A::new().a("name", "diagram")); + w.open0("dgm:varLst"); + w.empty0("dgm:dir"); + w.empty("dgm:resizeHandles", A::new().a("val", "exact")); + w.close("dgm:varLst"); + if kind == "hierarchy" { + // Each node over the row of its children, recursively. + w.empty("dgm:alg", A::new().a("type", "hierChild")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.open("dgm:forEach", A::new().a("name", "level").a("axis", "ch").a("ptType", "node")); + w.open("dgm:layoutNode", A::new().a("name", "hierRoot")); + w.empty("dgm:alg", A::new().a("type", "hierRoot")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + node_layout(&mut w, "roundRect", false); + w.open("dgm:layoutNode", A::new().a("name", "hierChild")); + w.empty("dgm:alg", A::new().a("type", "hierChild")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.empty("dgm:forEach", A::new().a("name", "repeat").a("ref", "level")); + w.close("dgm:layoutNode"); + w.close("dgm:layoutNode"); + w.close("dgm:forEach"); + } else { + let (ty, params, shape) = alg(kind); + w.open("dgm:alg", A::new().a("type", ty)); + for (k, v) in params { + w.empty("dgm:param", A::new().a("type", k).a("val", v)); + } + w.close("dgm:alg"); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.open0("dgm:constrLst"); + w.empty("dgm:constr", A::new().a("type", "w").a("for", "ch").a("forName", "node").a("refType", "w")); + w.empty( + "dgm:constr", + A::new().a("type", "h").a("for", "ch").a("forName", "node").a("refType", "w").a("refFor", "ch").a("refForName", "node").a("fact", "0.6"), + ); + w.empty( + "dgm:constr", + A::new().a("type", "w").a("for", "ch").a("forName", "space").a("refType", "w").a("refFor", "ch").a("refForName", "node").a("fact", "0.1"), + ); + w.empty("dgm:constr", A::new().a("type", "primFontSz").a("for", "ch").a("forName", "node").a("op", "equ").a("val", "65")); + w.close("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.open("dgm:forEach", A::new().a("name", "nodes").a("axis", "ch").a("ptType", "node")); + node_layout(&mut w, shape, true); + if kind != "cycle" { + space(&mut w); + } + w.close("dgm:forEach"); + } + w.close("dgm:layoutNode"); + w.close("dgm:layoutDef"); + w.finish() +} + +/// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. +pub fn style_xml() -> Vec { + let mut w = W::new(); + w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", QS_ID)); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + w.open0("dgm:catLst"); + w.empty("dgm:cat", A::new().a("type", "simple").a("pri", "10100")); + w.close("dgm:catLst"); + w.open0("dgm:scene3d"); + w.empty("a:camera", A::new().a("prst", "orthographicFront")); + w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); + w.close("dgm:scene3d"); + w.open("dgm:styleLbl", A::new().a("name", "node1")); + w.open0("dgm:scene3d"); + w.empty("a:camera", A::new().a("prst", "orthographicFront")); + w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); + w.close("dgm:scene3d"); + w.empty0("dgm:sp3d"); + w.empty("dgm:txPr", A::new()); + w.open0("dgm:style"); + for (tag, idx) in [("a:lnRef", "2"), ("a:fillRef", "1"), ("a:effectRef", "0")] { + w.open(tag, A::new().a("idx", idx)); + w.empty("a:scrgbClr", A::new().a("r", "0").a("g", "0").a("b", "0")); + w.close(tag); + } + w.open("a:fontRef", A::new().a("idx", "minor")); + w.empty("a:schemeClr", A::new().a("val", "lt1")); + w.close("a:fontRef"); + w.close("dgm:style"); + w.close("dgm:styleLbl"); + w.close("dgm:styleDef"); + w.finish() +} + +/// `ppt/diagrams/colorsN.xml`: accent 1 fills with light text. +pub fn colors_xml() -> Vec { + let mut w = W::new(); + w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", CS_ID)); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + w.open0("dgm:catLst"); + w.empty("dgm:cat", A::new().a("type", "accent1").a("pri", "11200")); + w.close("dgm:catLst"); + w.open("dgm:styleLbl", A::new().a("name", "node1")); + for (tag, clr) in [("dgm:fillClrLst", "accent1"), ("dgm:linClrLst", "lt1")] { + w.open(tag, A::new().a("meth", "repeat")); + w.empty("a:schemeClr", A::new().a("val", clr)); + w.close(tag); + } + w.empty0("dgm:effectClrLst"); + w.empty0("dgm:txLinClrLst"); + w.empty0("dgm:txFillClrLst"); + w.empty0("dgm:txEffectClrLst"); + w.close("dgm:styleLbl"); + w.close("dgm:colorsDef"); + w.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn nodes(v: &[(&str, u8)]) -> Vec { + v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l }).collect() + } + + #[test] + fn outline_survives_the_data_model() { + let d = + Diagram { kind: "hierarchy".into(), nodes: nodes(&[("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1), ("Board", 0)]), ..Default::default() }; + let xml = data_xml(&d, 7, "rId9"); + let doc = crate::xml::parse(&xml).unwrap(); + assert_eq!(outline(&doc.root), d.nodes); + let s = String::from_utf8(xml).unwrap(); + assert!(s.contains("hierarchy1") && s.contains("relId=\"rId9\"")); + for x in [layout_xml("hierarchy"), layout_xml("cycle"), style_xml(), colors_xml()] { + assert!(crate::xml::parse(&x).is_ok()); + } + } + + #[test] + fn hostile_links_are_bounded() { + let xml = br#"A"#; + let doc = crate::xml::parse(xml).unwrap(); + assert_eq!(outline(&doc.root), nodes(&[("A", 0), ("", 1)])); + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "hierarchy"); + assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "process"); + assert_eq!(kind_for("urn:x/layout/default", "list"), "list"); + } +} diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index 92274b62..a590aca8 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] mod custgeom; +mod dgm; mod maps; mod opc; mod png; diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c4b3f77f..c33dcc03 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -432,9 +432,49 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> let mut g = frame.clone(); let x = frame.xfrm.unwrap_or_default(); g.kind = ShapeKind::Group { children, child: Xfrm::new(0.0, 0.0, x.w, x.h) }; + g.diagram = diagram_outline(imp, part, rel_ids, &data.root); Some(g) } +/// The outline of a SmartArt graphic (for the Text Pane), with its layout, quick style and colour +/// parts kept to be written back. +fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option { + let nodes = crate::dgm::outline(data); + if nodes.is_empty() { + return None; + } + let raw = |attr: &str| -> Option { + let rel = part.rels.get(rel_ids.attr(attr)?)?; + let bytes = imp.pkg.get(&rel.target)?; + (bytes.len() <= 4 << 20).then(|| String::from_utf8(bytes.to_vec()).ok()).flatten() + }; + let layout_xml = raw("r:lo"); + let (layout_id, cat) = layout_xml + .as_deref() + .and_then(|x| crate::xml::parse(x.as_bytes()).ok()) + .map(|d| { + let cat = d.root.child("catLst").and_then(|c| c.child("cat")).and_then(|c| c.attr("type")).unwrap_or("").to_string(); + (d.root.attr("uniqueId").unwrap_or("").to_string(), cat) + }) + .unwrap_or_default(); + let lo_type = data + .path(&["ptLst"]) + .and_then(|l| l.children_named("pt").find(|p| p.attr("type") == Some("doc"))) + .and_then(|p| p.child("prSet")) + .and_then(|p| p.attr("loTypeId")) + .unwrap_or(""); + let id = if layout_id.is_empty() { lo_type.to_string() } else { layout_id }; + Some(deckcraft_model::Diagram { + kind: crate::dgm::kind_for(&id, &cat).to_string(), + nodes, + imported: true, + layout_id: id, + layout_xml, + style_xml: raw("r:qs"), + colors_xml: raw("r:cs"), + }) +} + fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { // A table naming no style is drawn by PowerPoint with thin dark grid lines ("No Style, Table // Grid"), not without lines. diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..96e95dc5 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -43,6 +43,7 @@ pub struct Exp<'a> { n_image: u32, n_media: u32, n_chart: u32, + n_diagram: u32, pub slide_parts: HashMap, pub table_styles: Vec, placeholder: Option, @@ -158,6 +159,11 @@ impl Exp<'_> { }; o.rels.add("image", &opc::relative(&o.name, &part), false) } + /// The number of the next SmartArt graphic's parts. + pub fn next_diagram(&mut self) -> u32 { + self.n_diagram += 1; + self.n_diagram + } pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String { self.n_chart += 1; let name = format!("ppt/charts/chart{}.xml", self.n_chart); @@ -385,6 +391,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { n_image: 0, n_media: 0, n_chart: 0, + n_diagram: 0, slide_parts: HashMap::new(), table_styles: vec![], placeholder: None, diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..bb3b63ac 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -202,7 +202,11 @@ fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) { } fn style(w: &mut W, st: &ShapeStyle) { - w.open0("p:style"); + style_tag(w, st, "p:style"); +} + +fn style_tag(w: &mut W, st: &ShapeStyle, tag: &str) { + w.open0(tag); for (tag, (idx, c)) in [("a:lnRef", &st.line_ref), ("a:fillRef", &st.fill_ref), ("a:effectRef", &st.effect_ref)] { w.open(tag, A::new().a("idx", idx)); dml::color(w, c); @@ -220,12 +224,16 @@ fn style(w: &mut W, st: &ShapeStyle) { } None => w.empty("a:fontRef", A::new().a("idx", idx)), } - w.close("p:style"); + w.close(tag); } /// `p:spPr` contents (also used for pictures and connectors). fn sp_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) { - w.open0("p:spPr"); + sp_pr_tag(w, x, o, s, geom, "p:spPr"); +} + +fn sp_pr_tag(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool, tag: &str) { + w.open0(tag); if let Some(xf) = &s.xfrm { dml::xfrm(w, "a:xfrm", xf, None); } @@ -244,7 +252,7 @@ fn sp_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) { w.raw(raw); } } - w.close("p:spPr"); + w.close(tag); } /// Write the children of a shape tree. @@ -297,6 +305,11 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } w.close("p:pic"); } + ShapeKind::Group { children, child } if s.diagram.is_some() && depth < 32 => { + if let Some(d) = &s.diagram { + diagram(w, x, o, cx, s, d, children, child); + } + } ShapeKind::Group { children, child } => { let id = cx.id_for(s); w.open0("p:grpSp"); @@ -400,6 +413,84 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } } +/// A SmartArt graphic: a graphic frame naming its data, layout, quick style and colour parts, +/// with the group's shapes as the cached drawing PowerPoint shows until it lays the diagram out. +#[allow(clippy::too_many_arguments)] +fn diagram(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, d: &deckcraft_model::Diagram, children: &[Shape], child: &Xfrm) { + use crate::dgm; + let id = cx.id_for(s); + let n = x.next_diagram(); + let frame = s.xfrm.unwrap_or(*child); + let name = |kind: &str| format!("ppt/diagrams/{kind}{n}.xml"); + // The drawing: the group's shapes in the frame's own coordinates. + let dname = name("drawing"); + let mut dout = Out::new(&dname); + let mut dw = W::new(); + dw.open( + "dsp:drawing", + A::new().a("xmlns:dgm", dgm::NS_DGM).a("xmlns:dsp", dgm::NS_DSP).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R), + ); + dw.open0("dsp:spTree"); + dw.open0("dsp:nvGrpSpPr"); + dw.empty("dsp:cNvPr", A::new().a("id", 0).a("name", "")); + dw.empty0("dsp:cNvGrpSpPr"); + dw.close("dsp:nvGrpSpPr"); + dw.empty0("dsp:grpSpPr"); + let (sx, sy) = (frame.w / child.w.max(1e-6), frame.h / child.h.max(1e-6)); + let mut k = 0; + let mut flat = vec![]; + walk(children, &mut |c, _| { + if matches!(c.kind, ShapeKind::Shape) { + flat.push(c.clone()); + } + }); + for mut c in flat.into_iter().take(10_000) { + if let Some(xf) = &mut c.xfrm { + *xf = Xfrm { x: (xf.x - child.x) * sx, y: (xf.y - child.y) * sy, w: xf.w * sx, h: xf.h * sy, ..*xf }; + } + k += 1; + dw.open("dsp:sp", A::new().a("modelId", format!("{{{:08X}-5D1A-4C0A-9E5B-{k:012X}}}", s.id.0))); + dw.open0("dsp:nvSpPr"); + dw.empty("dsp:cNvPr", A::new().a("id", 0).a("name", "")); + dw.empty0("dsp:cNvSpPr"); + dw.close("dsp:nvSpPr"); + sp_pr_tag(&mut dw, x, &mut dout, &c, true, "dsp:spPr"); + if let Some(st) = &c.style { + style_tag(&mut dw, st, "dsp:style"); + } + if let Some(tb) = &c.text { + dml::text_body(&mut dw, x, &mut dout, "dsp:txBody", tb, false); + } + dw.close("dsp:sp"); + } + dw.close("dsp:spTree"); + dw.close("dsp:drawing"); + x.pkg.xml_part(&dname, dgm::CT_DRAWING, dw.finish()); + x.pkg.rels(&dname, &dout.rels); + let rel = |o: &mut Out, kind: &str, part: &str| o.rels.add_new(kind, &crate::opc::relative(&o.name, part), false); + let drawing_rel = rel(o, dgm::RT_DRAWING, &dname); + let data = name("data"); + x.pkg.xml_part(&data, dgm::CT_DATA, dgm::data_xml(d, s.id.0, &drawing_rel)); + let dm = rel(o, "diagramData", &data); + let layout = name("layout"); + let lo_xml = d.layout_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::layout_xml(&d.kind)); + x.pkg.xml_part(&layout, dgm::CT_LAYOUT, lo_xml); + let lo = rel(o, "diagramLayout", &layout); + let style = name("quickStyle"); + x.pkg.xml_part(&style, dgm::CT_STYLE, d.style_xml.clone().map(String::into_bytes).unwrap_or_else(dgm::style_xml)); + let qs = rel(o, "diagramQuickStyle", &style); + let colors = name("colors"); + x.pkg.xml_part(&colors, dgm::CT_COLORS, d.colors_xml.clone().map(String::into_bytes).unwrap_or_else(dgm::colors_xml)); + let cs = rel(o, "diagramColors", &colors); + let mut fs = s.clone(); + fs.xfrm = Some(frame); + frame_open(w, x, o, id, &fs); + w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/diagram")); + w.empty("dgm:relIds", A::new().a("xmlns:dgm", dgm::NS_DGM).a("r:dm", dm).a("r:lo", lo).a("r:qs", qs).a("r:cs", cs)); + w.close("a:graphicData"); + frame_close(w); +} + fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { let pts: Vec<(f64, f64)> = st.points.iter().filter(|p| p.0.is_finite() && p.1.is_finite()).map(|p| (p.0, p.1)).collect(); let (x0, y0) = pts.iter().fold((f64::MAX, f64::MAX), |a, p| (a.0.min(p.0), a.1.min(p.1))); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d1427027..8f265645 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -74,7 +74,7 @@ the round trip even though DeckCraft doesn't model it. | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | -| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save (DeckCraft keeps its own outline in `.deckcraft` only) | +| SmartArt (`dgm`) | the drawing part as a group, and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | | Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink | diff --git a/docs/gaps.md b/docs/gaps.md index b737232c..3c7d5b31 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -151,14 +151,16 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). ### SmartArt editing is shallow -- **Evidence:** a SmartArt graphic inserted in DeckCraft keeps its outline (`Shape.diagram`) and - has a Text Pane, Add Shape (after, before, above, below), Promote, Demote, Remove and layout - switching between DeckCraft's six layouts (`crates/engine/src/cmd/smartart.rs`). But - `crates/pptx/src/read/shapes.rs` reads a PowerPoint diagram's drawing part as a plain group (no - outline, so no Text Pane), the outline isn't written to `.pptx` (`dgm` data), and there are six - layouts rather than PowerPoint's ~200. -- **Impact:** editing a SmartArt diagram from PowerPoint breaks it. -- **Estimate:** 8–14 h. +- **Evidence:** a SmartArt graphic keeps its outline (`Shape.diagram`) and has a Text Pane, Add + Shape, Promote, Demote, Remove and layout switching (`crates/engine/src/cmd/smartart.rs`). On + `.pptx` the outline is written as a real diagram (data, layout, quick style, colours and the + cached drawing; `crates/pptx/src/dgm.rs`), and PowerPoint diagrams read their outline from the + data model, so they get the Text Pane too. Still missing: DeckCraft's own layout definitions are + simple (not yet checked in PowerPoint itself); there are six layouts rather than ~200; editing an + imported diagram's outline redraws it with DeckCraft's layout; a diagram with no cached drawing + isn't drawn. +- **Impact:** a PowerPoint diagram edited in DeckCraft changes look. +- **Estimate:** 6–10 h. ### Animation Pane, effect count and preview Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. From 1866de9895d8ae1b7f76a98b33d190d07756de2c Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:42:52 +0300 Subject: [PATCH 04/17] SmartArt: 15 layouts, SmartArt Styles and Change Colors Layouts now include Vertical and Horizontal Bullet List, Basic Block List, Basic/Chevron/Vertical/Step Up Process, Basic and Block Cycle, Basic Radial, Basic and Inverted Pyramid (bands drawn as polygons so the slope and the text stay right), Basic and Titled Matrix, and Hierarchy. Each layout says which nodes each shape shows, so typed text maps back for any layout. SmartArt Styles (flat, subtle, intense, white outline) and Change Colors (colourful or one accent) are kept on the diagram, offered in the SmartArt Design tab, and written to .pptx as quick style / colour definition ids (and read back from them). Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 463 ++++++++++++++++++++++++++---- crates/engine/src/tests.rs | 5 +- crates/model/src/lib.rs | 9 +- crates/pptx/src/dgm.rs | 164 +++++++++-- crates/pptx/src/read/shapes.rs | 5 + crates/pptx/src/write/shapes.rs | 4 +- crates/ui-egui/src/ribbon.rs | 31 +- docs/gaps.md | 6 +- 8 files changed, 585 insertions(+), 102 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index cfca7620..331dabec 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -7,8 +7,11 @@ use deckcraft_color::SchemeSlot; use deckcraft_geom::Xfrm; +use deckcraft_model::style::{Gradient, GradientShape, GradientStop, Shadow}; use deckcraft_model::text::{Align, AutoFit, Paragraph, TextBody}; -use deckcraft_model::{Diagram, DiagramNode, Fill, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle}; +use deckcraft_model::{ + ColorRef, ColorTransform, Diagram, DiagramNode, Effects, Fill, Geom, Line, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, +}; use serde_json::{Value, json}; use super::*; @@ -19,7 +22,41 @@ pub const MAX_LEVEL: u8 = 8; /// Most top-level shapes a layout draws. const MAX_SHAPES: usize = 40; -pub const KINDS: [&str; 6] = ["list", "process", "cycle", "hierarchy", "pyramid", "matrix"]; +/// DeckCraft's layouts, with the names PowerPoint's gallery gives them. +pub const LAYOUTS: [(&str, &str); 15] = [ + ("list", "Vertical Bullet List"), + ("blockList", "Basic Block List"), + ("hBulletList", "Horizontal Bullet List"), + ("process", "Basic Process"), + ("chevron", "Basic Chevron Process"), + ("verticalProcess", "Vertical Process"), + ("stepUp", "Step Up Process"), + ("cycle", "Basic Cycle"), + ("blockCycle", "Block Cycle"), + ("radial", "Basic Radial"), + ("hierarchy", "Hierarchy"), + ("pyramid", "Basic Pyramid"), + ("invertedPyramid", "Inverted Pyramid"), + ("matrix", "Basic Matrix"), + ("titledMatrix", "Titled Matrix"), +]; +/// SmartArt Styles. +pub const STYLES: [(&str, &str); 4] = + [("flat", "Simple Fill"), ("subtle", "Subtle Effect"), ("intense", "Intense Effect"), ("outline", "White Outline")]; +/// Change Colors. +pub const COLORS: [(&str, &str); 7] = [ + ("colorful", "Colorful"), + ("accent1", "Accent 1"), + ("accent2", "Accent 2"), + ("accent3", "Accent 3"), + ("accent4", "Accent 4"), + ("accent5", "Accent 5"), + ("accent6", "Accent 6"), +]; + +pub fn is_kind(k: &str) -> bool { + LAYOUTS.iter().any(|(id, _)| *id == k) +} pub fn specs() -> Vec { vec![ @@ -66,10 +103,28 @@ pub fn specs() -> Vec { "Change Layout", ["SmartArt Design", "Layouts"], None, - "{id?, kind: list|process|cycle|hierarchy|pyramid|matrix}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix}", has_selection, change_layout ), + cmd!( + "smartArt.style", + "SmartArt Styles", + ["SmartArt Design", "SmartArt Styles"], + None, + "{id?, style: flat|subtle|intense|outline}", + has_selection, + change_style + ), + cmd!( + "smartArt.colors", + "Change Colors", + ["SmartArt Design", "SmartArt Styles"], + None, + "{id?, colors: colorful|accent1|…|accent6}", + has_selection, + change_colors + ), ] } @@ -175,11 +230,11 @@ pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool { true } -/// Which nodes each generated text shape shows, in child order (lines drawn after these carry none). -fn node_map(d: &Diagram) -> Vec> { - if d.kind == "hierarchy" { - return (0..d.nodes.len().min(MAX_SHAPES)).map(|i| vec![i]).collect(); - } +/// A generated shape and the nodes whose text it shows (none for arrows and lines). +type Item = (Shape, Vec); + +/// Top-level nodes, each with its sub-points. +fn groups(d: &Diagram) -> Vec> { let mut out: Vec> = vec![]; for (i, n) in d.nodes.iter().enumerate() { match out.last_mut() { @@ -187,8 +242,7 @@ fn node_map(d: &Diagram) -> Vec> { _ => out.push(vec![i]), } } - let cap = if d.kind == "matrix" { 4 } else { MAX_SHAPES }; - out.truncate(cap); + out.truncate(MAX_SHAPES); out } @@ -197,9 +251,10 @@ fn sync_text(d: &mut Diagram, children: &[Shape]) { if d.imported { return; } - for (shape, idx) in children.iter().zip(node_map(d)) { + let map: Vec> = layout_items(d, Xfrm::new(0.0, 0.0, 100.0, 100.0)).into_iter().map(|(_, m)| m).collect(); + for (shape, idx) in children.iter().zip(map) { let Some(body) = &shape.text else { continue }; - if body.paragraphs.len() != idx.len() { + if idx.is_empty() || body.paragraphs.len() != idx.len() { continue; } for (p, i) in body.paragraphs.iter().zip(idx) { @@ -214,13 +269,91 @@ fn slot(i: usize) -> SchemeSlot { [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] } -fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Shape { +/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN` +/// keeps one. +fn accent(d: &Diagram, i: usize) -> SchemeSlot { + match d.colors.strip_prefix("accent").and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => slot(n - 1), + _ => slot(i), + } +} + +/// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet +/// boxes of the bullet lists) are a pale tint of the accent with dark text. +fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { + let a = accent(d, i); + let base = ColorRef::scheme(a); + let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000)); + let dark_text = light || d.style == "outline"; + sh.style = Some(ShapeStyle::accent(a)); + match d.style.as_str() { + _ if light => { + sh.fill = Some(Fill::solid(tint(80))); + sh.line = Some(Line::solid(tint(60), 1.0)); + } + "outline" => { + sh.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); + sh.line = Some(Line::solid(base.clone(), 2.25)); + } + "subtle" => { + sh.fill = Some(Fill::solid(base.clone())); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() }); + } + "intense" => { + sh.fill = Some(Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: tint(30) }, + GradientStop { pos: 1.0, color: base.clone().with(ColorTransform::LumMod(75_000)) }, + ], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)); + sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() }); + } + _ => { + sh.fill = Some(Fill::solid(base.clone())); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + } + if let Some(body) = &mut sh.text { + let c = if dark_text { ColorRef::scheme(SchemeSlot::Tx1) } else { ColorRef::scheme(SchemeSlot::Lt1) }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } +} + +fn shadow(blur: f64, dist: f64) -> Shadow { + Shadow { + color: ColorRef::rgb(deckcraft_model::Rgba { r: 0, g: 0, b: 0, a: 255 }).with(ColorTransform::Alpha(40_000)), + blur, + dist, + dir: 90.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + } +} + +/// A shape with text: the first line as the title (centred, larger), the rest as bullets. With +/// `title` false every line is a bullet (the bullet boxes of the bullet lists). +fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape { let mut body = TextBody::default(); body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); - let top = lines.first().map(|n| n.level).unwrap_or(0); + let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0); for (k, n) in lines.iter().enumerate() { let mut para = Paragraph::new(&n.text); - if k == 0 { + let head = title && k == 0; + if head { para.props.align = Some(Align::Center); } else { para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL); @@ -229,7 +362,7 @@ fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Sh para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0)); para.props.indent = Some(-10.0); } - let size = if k == 0 { 20.0 } else { 14.0 }; + let size = if head { 20.0 } else { 14.0 }; for r in &mut para.runs { r.props.size = Some(size); } @@ -239,14 +372,17 @@ fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Sh if body.paragraphs.is_empty() { body.paragraphs.push(Paragraph::new("")); } - Shape { - name: format!("SmartArt shape {}", color + 1), - xfrm: Some(x), - geom: Geom::preset(preset), - style: Some(ShapeStyle::accent(slot(color))), - text: Some(body), - ..Default::default() + if !title { + body.body.anchor = Some(deckcraft_model::text::Anchor::Top); } + Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() } +} + +/// A plain shape with no text (arrows between steps). +fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape { + let mut x = x; + x.rot = rot; + Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() } } fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { @@ -256,63 +392,205 @@ fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { name: "SmartArt connector".into(), xfrm: Some(x), geom: Geom::preset("line"), - style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::LumMod(60_000)), 1.25)), ..Default::default() } } /// The diagram's shapes laid out in `area` (ids not yet assigned). pub fn layout(d: &Diagram, area: Xfrm) -> Vec { - let map = node_map(d); - let groups: Vec> = map.iter().map(|g| g.iter().filter_map(|i| d.nodes.get(*i)).collect()).collect(); - let n = groups.len().max(1); - let mut out = vec![]; + layout_items(d, area).into_iter().map(|(s, _)| s).collect() +} + +/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows. +fn layout_items(d: &Diagram, area: Xfrm) -> Vec { + let gs = groups(d); + let nodes = |g: &[usize]| -> Vec<&DiagramNode> { g.iter().filter_map(|i| d.nodes.get(*i)).collect() }; + let n = gs.len().max(1); + let nf = n as f64; + let mut out: Vec = vec![]; + // A titled shape for top-level group `i`, painted in its accent. + let titled = |out: &mut Vec, preset: &str, x: Xfrm, i: usize, g: &[usize]| { + let mut sh = text_shape(preset, x, &nodes(g), true); + paint(d, &mut sh, i, false); + out.push((sh, g.to_vec())); + }; + let arrow = |out: &mut Vec, x: Xfrm, rot: f64, i: usize| { + let mut sh = deco("rightArrow", x, rot); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + }; match d.kind.as_str() { "hierarchy" => return hierarchy(d, area), - "cycle" => { - let r = area.h.min(area.w) * 0.38; + "list" | "hBulletList" => { + // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. + let vertical = d.kind == "list"; + for (i, g) in gs.iter().enumerate() { + let (cell, bar_h) = if vertical { + ( + Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), + (area.h / nf * 0.92).min(area.h * 0.12).max(18.0), + ) + } else { + let w = area.w / nf; + (Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22) + }; + let has_sub = g.len() > 1; + let bar_h = bar_h.min(cell.h); + titled(&mut out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]); + if has_sub { + let pad = cell.w * 0.03; + let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0)); + let mut sh = text_shape("rect", x, &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + out.push((sh, g[1..].to_vec())); + } + } + } + "blockList" => { + // Basic Block List: equal blocks in rows, as square as the area allows. + let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (w, h) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w) / 2.0; + titled(&mut out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g); + } + } + "process" | "chevron" => { + let chevron = d.kind == "chevron"; + let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 }; + let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0); + let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 }); + let y = area.y + (area.h - h) / 2.0; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + titled(&mut out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g); + if !chevron && i + 1 < n { + let aw = gap * 0.7; + arrow(&mut out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i); + } + } + } + "verticalProcess" => { + let gap = area.h * 0.06; + let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0); + let w = area.w * 0.6; + let x = area.x + (area.w - w) / 2.0; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * (h + gap); + titled(&mut out, "roundRect", Xfrm::new(x, y, w, h), i, g); + if i + 1 < n { + let s = gap * 0.8; + // A right arrow turned to point down. + arrow(&mut out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i); + } + } + } + "stepUp" => { + // Step Up Process: each step higher than the one before. + let w = area.w / nf; + let h = area.h / (nf + 1.0) * 1.6; + for (i, g) in gs.iter().enumerate() { + let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0); + titled(&mut out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g); + } + } + "cycle" | "blockCycle" => { + let block = d.kind == "blockCycle"; + let r = area.h.min(area.w) * 0.36; let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = r * 0.62; - for (i, g) in groups.iter().enumerate() { - let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); - out.push(text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), i, g)); + let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) }; + titled( + &mut out, + if block { "roundRect" } else { "ellipse" }, + Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), + i, + g, + ); + if n > 1 { + // An arrow halfway to the next node, along the circle. + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.35).max(6.0); + let rot = (i as f64 + 0.5) / nf * 360.0; + arrow(&mut out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i); + } } } - "pyramid" => { - let h = area.h / n as f64; - for (i, g) in groups.iter().enumerate() { - let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64; - out.push(text_shape("trapezoid", Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i, g)); + "radial" => { + // Basic Radial: the first node in the middle, the others around it with spokes. + let Some((centre, rest)) = gs.split_first() else { return out }; + let m = rest.len().max(1) as f64; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = r * 0.75; + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7); + for (k, _) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin()); + out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![])); + } + titled(&mut out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre); + for (k, g) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + titled(&mut out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g); } } - "matrix" => { - let (w, h) = (area.w / 2.0, area.h / 2.0); - for (i, g) in groups.iter().enumerate() { - let x = Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0); - out.push(text_shape("roundRect", x, i, g)); + "pyramid" | "invertedPyramid" => { + // One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as + // its own polygon so the sides keep the triangle's slope and the text stays upright. + let inverted = d.kind == "invertedPyramid"; + let h = area.h / nf; + let pw = area.h.min(area.w) * 1.15; + let px = area.x + (area.w - pw) / 2.0; + for (i, g) in gs.iter().enumerate() { + let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf); + let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) }; + let (wt, wb) = (pw * t, pw * bt); + let w = wt.max(wb).max(1.0); + let bh = h * 0.96; + let x = px + (pw - w) / 2.0; + let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0); + let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &nodes(g), true); + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], + }; + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); } } - "list" => { - let h = area.h / n as f64; - for (i, g) in groups.iter().enumerate() { - out.push(text_shape("roundRect", Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i, g)); + "matrix" | "titledMatrix" => { + let titled_m = d.kind == "titledMatrix"; + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let (quads, title): (&[Vec], Option<&Vec>) = + if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (&gs, None) }; + for (i, g) in quads.iter().take(4).enumerate() { + let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0); + titled(&mut out, "roundRect", x, i + usize::from(titled_m), g); } - } - _ => { - let gap = 24.0; - let w = ((area.w - gap * (n as f64 - 1.0)) / n as f64).max(1.0); - for (i, g) in groups.iter().enumerate() { - let preset = if i + 1 < n { "homePlate" } else { "roundRect" }; - out.push(text_shape(preset, Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), i, g)); + if let Some(t) = title { + let c = s * 0.42; + let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &nodes(t), true); + paint(d, &mut sh, 0, true); + out.push((sh, t.clone())); } } + _ => {} } out } /// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. -fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { +fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; if nodes.is_empty() { return vec![]; @@ -375,11 +653,19 @@ fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { Xfrm::new(cx - bw / 2.0, area.y + depth[i] as f64 * row_h + (row_h - bh) / 2.0, bw, bh) }) .collect(); - let mut out: Vec = nodes.iter().enumerate().map(|(i, n)| text_shape("roundRect", boxes[i], depth[i], &[n])).collect(); + let mut out: Vec = nodes + .iter() + .enumerate() + .map(|(i, n)| { + let mut sh = text_shape("roundRect", boxes[i], &[n], true); + paint(d, &mut sh, depth[i], false); + (sh, vec![i]) + }) + .collect(); for i in 0..nodes.len() { if let Some(p) = parent[i] { let (a, b) = (boxes[p], boxes[i]); - out.push(line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y))); + out.push((line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)), vec![])); } } out @@ -457,7 +743,7 @@ fn index(p: &Value, cmd: &str) -> Result { /// Insert a new SmartArt graphic (`insert.smartArt`). pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { - let kind = if KINDS.contains(&kind) { kind } else { "process" }; + let kind = if is_kind(kind) { kind } else { "process" }; if nodes.is_empty() { nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect(); } @@ -542,9 +828,33 @@ fn demote(s: &mut Session, p: &Value) -> Result { edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) } +fn change_style(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.style"; + let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.style != v { + d.style_xml = None; + } + d.style = v; + Ok(Value::Null) + }) +} + +fn change_colors(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.colors"; + let v = str_param(p, "colors").filter(|k| COLORS.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown colours"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.colors != v { + d.colors_xml = None; + } + d.colors = v; + Ok(Value::Null) + }) +} + fn change_layout(s: &mut Session, p: &Value) -> Result { const CMD: &str = "smartArt.layout"; - let kind = str_param(p, "kind").filter(|k| KINDS.contains(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); + let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); edit_diagram(s, p, CMD, |d| { if d.kind != kind { // PowerPoint's own layout no longer applies. @@ -595,7 +905,7 @@ mod tests { #[test] fn layouts_map_nodes_to_shapes() { let area = Xfrm::new(0.0, 0.0, 800.0, 400.0); - let d = Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }; + let d = Diagram { kind: "blockList".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }; let v = layout(&d, area); assert_eq!(v.len(), 2); let t = v[0].text.as_ref().map(|b| b.paragraphs.len()); @@ -608,5 +918,36 @@ mod tests { assert!(x.x >= -0.01 && x.x + x.w <= 800.01 && x.y >= -0.01 && x.y + x.h <= 400.01, "{x:?}"); } assert!(layout(&Diagram { kind: "cycle".into(), ..Default::default() }, area).is_empty()); + // Vertical Bullet List: a bar per top node, its sub-points in a box below. + let v = layout_items(&Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }, area); + let maps: Vec> = v.into_iter().map(|(_, m)| m).collect(); + assert_eq!(maps, [vec![0], vec![1, 2], vec![3]]); + } + + #[test] + fn every_layout_shows_every_node_inside_its_area() { + let area = Xfrm::new(50.0, 40.0, 800.0, 400.0); + for (kind, _) in LAYOUTS { + for style in ["", "subtle", "intense", "outline"] { + let d = Diagram { + kind: kind.into(), + nodes: mk(&[0, 1, 0, 0, 1, 0]), + style: style.into(), + colors: "accent2".into(), + ..Default::default() + }; + let items = layout_items(&d, area); + let mut shown: Vec = items.iter().flat_map(|(_, m)| m.clone()).collect(); + shown.sort_unstable(); + let want: Vec = (0..6).collect(); + assert_eq!(shown, want, "{kind}"); + for (sh, _) in &items { + let x = sh.xfrm.unwrap_or_default(); + let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0); + assert!(x.w.is_finite() && x.h.is_finite() && x.w >= 0.0 && x.h >= 0.0, "{kind} {x:?}"); + assert!((49.0..=851.0).contains(&cx) && (39.0..=441.0).contains(&cy), "{kind} {x:?}"); + } + } + } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 179b6a4f..64b0942c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -839,7 +839,7 @@ fn slide_ranges_parse_like_powerpoints_print_box() { #[test] fn smartart_text_pane_edits_regenerate_the_diagram() { let mut s = session(); - let r = s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "\tScope", "Build"]})).unwrap(); + let r = s.execute("insert.smartArt", &json!({"kind": "blockList", "items": ["Plan", "\tScope", "Build"]})).unwrap(); let gid = r["id"].as_u64().unwrap(); let kids = |s: &Session| match s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).map(|x| &x.kind) { Some(deckcraft_model::ShapeKind::Group { children, .. }) => children.clone(), @@ -890,6 +890,8 @@ fn smartart_round_trips_through_pptx_as_a_diagram() { let mut s = session(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); s.execute("insert.smartArt", &json!({"kind": "hierarchy", "items": ["CEO", "\tCTO", "\t\tDev", "\tCFO"]})).unwrap(); + s.execute("smartArt.style", &json!({"style": "intense"})).unwrap(); + s.execute("smartArt.colors", &json!({"colors": "accent3"})).unwrap(); let doc = s.doc().unwrap().doc.as_ref().clone(); let bytes = deckcraft_pptx::export(&doc).unwrap(); let names: Vec = { @@ -904,6 +906,7 @@ fn smartart_round_trips_through_pptx_as_a_diagram() { let g = back.slides[1].shapes.iter().find(|x| x.diagram.is_some()).expect("a SmartArt group"); let d = g.diagram.as_ref().unwrap(); assert_eq!(d.kind, "hierarchy"); + assert_eq!((d.style.as_str(), d.colors.as_str()), ("intense", "accent3"), "SmartArt style and colours come back"); assert!(d.imported); let outline: Vec<(&str, u8)> = d.nodes.iter().map(|n| (n.text.as_str(), n.level)).collect(); assert_eq!(outline, [("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1)]); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 377f94ef..cfd30ab1 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -329,9 +329,16 @@ pub struct Shape { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Diagram { - /// list, process, cycle, hierarchy, pyramid or matrix. + /// The layout: list, blockList, hBulletList, process, chevron, verticalProcess, stepUp, cycle, + /// blockCycle, radial, hierarchy, pyramid, invertedPyramid, matrix or titledMatrix. pub kind: String, pub nodes: Vec, + /// SmartArt Style: `flat` (default), `subtle`, `intense` or `outline`. + #[serde(skip_serializing_if = "String::is_empty")] + pub style: String, + /// Change Colors: `colorful` (default: each shape its own accent) or `accent1`…`accent6`. + #[serde(skip_serializing_if = "String::is_empty")] + pub colors: String, /// The group's shapes are the file's drawing, not generated from `nodes`: text typed into them /// isn't mapped back to nodes (the first outline edit regenerates them). #[serde(skip_serializing_if = "std::ops::Not::not")] diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index a3af2702..993c771f 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -19,48 +19,130 @@ pub const CT_STYLE: &str = "application/vnd.openxmlformats-officedocument.drawin pub const CT_COLORS: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml"; pub const CT_DRAWING: &str = "application/vnd.ms-office.drawingml.diagramDrawing+xml"; pub const RT_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing"; -const QS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/simple1"; -const CS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/colors/accent1_2"; +const QS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/"; +const CS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/colors/"; + +/// PowerPoint's quick style for a SmartArt Style. +pub fn qs_id(style: &str) -> String { + let s = match style { + "subtle" => "simple3", + "intense" => "simple5", + "outline" => "simple4", + _ => "simple1", + }; + format!("{QS_BASE}{s}") +} + +/// The SmartArt Style closest to a quick style id. +pub fn style_for(qs: &str) -> &'static str { + match qs.rsplit('/').next().unwrap_or("") { + "simple1" | "" => "", + "simple4" => "outline", + "simple2" | "simple3" => "subtle", + _ => "intense", + } +} + +/// PowerPoint's colour definition for Change Colors. +pub fn cs_id(colors: &str) -> String { + match colors.strip_prefix("accent").and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => format!("{CS_BASE}accent{n}_2"), + _ => format!("{CS_BASE}colorful1"), + } +} + +/// Change Colors from a colour definition id: `accentN_…` keeps accent N, anything else is colourful. +pub fn colors_for(cs: &str) -> String { + let last = cs.rsplit('/').next().unwrap_or(""); + match last.strip_prefix("accent").and_then(|r| r.split('_').next()).and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => format!("accent{n}"), + _ => String::new(), + } +} /// Most nodes read from one diagram. const MAX_NODES: usize = 2000; /// PowerPoint's built-in layout DeckCraft's layout `kind` is written as. pub fn layout_id(kind: &str) -> &'static str { match kind { + "blockList" => "urn:microsoft.com/office/officeart/2005/8/layout/default", + "hBulletList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList1", "process" => "urn:microsoft.com/office/officeart/2005/8/layout/process1", + "chevron" => "urn:microsoft.com/office/officeart/2005/8/layout/chevron1", + "verticalProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process2", + "stepUp" => "urn:microsoft.com/office/officeart/2009/3/layout/StepUpProcess", "cycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle2", + "blockCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle5", + "radial" => "urn:microsoft.com/office/officeart/2005/8/layout/radial1", "hierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy1", "pyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid1", + "invertedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid3", "matrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix3", + "titledMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix1", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } /// The DeckCraft layout closest to a PowerPoint layout (by its id and category). pub fn kind_for(layout_id: &str, category: &str) -> &'static str { - let s = format!("{layout_id} {category}").to_ascii_lowercase(); + let last = layout_id.rsplit('/').next().unwrap_or("").to_ascii_lowercase(); + // Exact ids first: the layouts DeckCraft writes, and their closest siblings. + for k in [ + "blockList", + "hBulletList", + "process", + "chevron", + "verticalProcess", + "stepUp", + "cycle", + "blockCycle", + "radial", + "hierarchy", + "pyramid", + "invertedPyramid", + "matrix", + "titledMatrix", + "list", + ] { + if layout_id == layout_id_of(k) { + return k; + } + } + let s = format!("{last} {}", category.to_ascii_lowercase()); if s.contains("hierarchy") || s.contains("orgchart") { "hierarchy" - } else if s.contains("cycle") || s.contains("radial") { + } else if s.contains("radial") { + "radial" + } else if s.contains("cycle") { "cycle" } else if s.contains("pyramid") { "pyramid" } else if s.contains("matrix") { "matrix" - } else if s.contains("process") || s.contains("chevron") || s.contains("arrow") { + } else if s.contains("chevron") { + "chevron" + } else if s.contains("process") || s.contains("arrow") { "process" + } else if last == "default" { + "blockList" + } else if last.starts_with("hlist") { + "hBulletList" } else { "list" } } +fn layout_id_of(kind: &str) -> &'static str { + layout_id(kind) +} + fn category(kind: &str) -> &'static str { match kind { - "process" => "process", - "cycle" => "cycle", + "process" | "chevron" | "verticalProcess" | "stepUp" => "process", + "cycle" | "blockCycle" | "radial" => "cycle", "hierarchy" => "hierarchy", - "pyramid" => "pyramid", - "matrix" => "matrix", + "pyramid" | "invertedPyramid" => "pyramid", + "matrix" | "titledMatrix" => "matrix", _ => "list", } } @@ -101,10 +183,10 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec { A::new() .a("loTypeId", &lo) .a("loCatId", category(&d.kind)) - .a("qsTypeId", QS_ID) + .a("qsTypeId", qs_id(&d.style)) .a("qsCatId", "simple") - .a("csTypeId", CS_ID) - .a("csCatId", "accent1") + .a("csTypeId", cs_id(&d.colors)) + .a("csCatId", if d.colors.starts_with("accent") { d.colors.as_str() } else { "colorful" }) .a("phldr", "1"), ); w.empty0("dgm:spPr"); @@ -230,10 +312,20 @@ fn paragraphs(t: &El) -> String { /// The layout algorithm and node shape of a DeckCraft layout. fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static str) { match kind { - "process" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), - "cycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), - "pyramid" => ("pyra", vec![("linDir", "fromT"), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid"), - "matrix" => ("snake", vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], "roundRect"), + "process" | "stepUp" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), + "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), + "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), + "cycle" | "radial" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), + "blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"), + "pyramid" | "invertedPyramid" => { + ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") + } + "matrix" | "titledMatrix" | "blockList" => ( + "snake", + vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], + if kind == "blockList" { "rect" } else { "roundRect" }, + ), + "hBulletList" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "t")], "roundRect"), _ => ("lin", vec![("linDir", "fromT"), ("vertAlign", "mid")], "roundRect"), } } @@ -348,7 +440,7 @@ pub fn layout_xml(kind: &str) -> Vec { w.empty0("dgm:ruleLst"); w.open("dgm:forEach", A::new().a("name", "nodes").a("axis", "ch").a("ptType", "node")); node_layout(&mut w, shape, true); - if kind != "cycle" { + if !matches!(kind, "cycle" | "blockCycle" | "radial") { space(&mut w); } w.close("dgm:forEach"); @@ -359,9 +451,9 @@ pub fn layout_xml(kind: &str) -> Vec { } /// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. -pub fn style_xml() -> Vec { +pub fn style_xml(style: &str) -> Vec { let mut w = W::new(); - w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", QS_ID)); + w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", qs_id(style))); w.empty("dgm:title", A::new().a("val", "")); w.empty("dgm:desc", A::new().a("val", "")); w.open0("dgm:catLst"); @@ -394,20 +486,30 @@ pub fn style_xml() -> Vec { } /// `ppt/diagrams/colorsN.xml`: accent 1 fills with light text. -pub fn colors_xml() -> Vec { +pub fn colors_xml(colors: &str) -> Vec { let mut w = W::new(); - w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", CS_ID)); + w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", cs_id(colors))); w.empty("dgm:title", A::new().a("val", "")); w.empty("dgm:desc", A::new().a("val", "")); + let single = colors.starts_with("accent"); w.open0("dgm:catLst"); - w.empty("dgm:cat", A::new().a("type", "accent1").a("pri", "11200")); + w.empty("dgm:cat", A::new().a("type", if single { colors } else { "colorful" }).a("pri", "11200")); w.close("dgm:catLst"); w.open("dgm:styleLbl", A::new().a("name", "node1")); - for (tag, clr) in [("dgm:fillClrLst", "accent1"), ("dgm:linClrLst", "lt1")] { - w.open(tag, A::new().a("meth", "repeat")); - w.empty("a:schemeClr", A::new().a("val", clr)); - w.close(tag); + if single { + w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); + w.empty("a:schemeClr", A::new().a("val", colors)); + w.close("dgm:fillClrLst"); + } else { + w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); + for k in 1..=6 { + w.empty("a:schemeClr", A::new().a("val", format!("accent{k}"))); + } + w.close("dgm:fillClrLst"); } + w.open("dgm:linClrLst", A::new().a("meth", "repeat")); + w.empty("a:schemeClr", A::new().a("val", "lt1")); + w.close("dgm:linClrLst"); w.empty0("dgm:effectClrLst"); w.empty0("dgm:txLinClrLst"); w.empty0("dgm:txFillClrLst"); @@ -434,7 +536,7 @@ mod tests { assert_eq!(outline(&doc.root), d.nodes); let s = String::from_utf8(xml).unwrap(); assert!(s.contains("hierarchy1") && s.contains("relId=\"rId9\"")); - for x in [layout_xml("hierarchy"), layout_xml("cycle"), style_xml(), colors_xml()] { + for x in [layout_xml("hierarchy"), layout_xml("cycle"), style_xml("subtle"), colors_xml(""), colors_xml("accent3")] { assert!(crate::xml::parse(&x).is_ok()); } } @@ -445,7 +547,11 @@ mod tests { let doc = crate::xml::parse(xml).unwrap(); assert_eq!(outline(&doc.root), nodes(&[("A", 0), ("", 1)])); assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "hierarchy"); - assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "process"); - assert_eq!(kind_for("urn:x/layout/default", "list"), "list"); + assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "chevron"); + assert_eq!(kind_for("urn:x/layout/hProcess9", "process"), "process"); + assert_eq!(kind_for("urn:x/layout/default", "list"), "blockList"); + assert_eq!(colors_for(&cs_id("accent4")), "accent4"); + assert_eq!(colors_for(&cs_id("")), ""); + assert_eq!(style_for(&qs_id("outline")), "outline"); } } diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c33dcc03..34d7bbc1 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -464,9 +464,14 @@ fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option (Some(id.0), d.nodes.len(), d.kind.clone()), - None => (None, 0, String::new()), + let (gid, n, kind, style, colors) = match &sel { + Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone(), d.style.clone(), d.colors.clone()), + None => (None, 0, String::new(), String::new(), String::new()), }; let at = row.min(n.saturating_sub(1)); group(ui, |ui| { @@ -2356,16 +2356,35 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { }); }); group(ui, |ui| { - let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", gid.is_some(), false, true); + let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_engine::cmd::smartart::KINDS { - let label = format!("{}{}", k[..1].to_uppercase(), &k[1..]); + for (k, label) in deckcraft_engine::cmd::smartart::LAYOUTS { if ui.selectable_label(kind == k, label).clicked() { run(app, "smartArt.layout", json!({"id": gid, "kind": k})); } } }); }); + group(ui, |ui| { + let (r, _) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + for (k, label) in deckcraft_engine::cmd::smartart::COLORS { + let on = colors == k || (colors.is_empty() && k == "colorful"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.colors", json!({"id": gid, "colors": k})); + } + } + }); + let (r, _) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + for (k, label) in deckcraft_engine::cmd::smartart::STYLES { + let on = style == k || (style.is_empty() && k == "flat"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.style", json!({"id": gid, "style": k})); + } + } + }); + }); arrange_group(app, ui); } diff --git a/docs/gaps.md b/docs/gaps.md index 3c7d5b31..28fd297a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -155,8 +155,10 @@ Detail in [file-format-parity.md](file-format-parity.md). Shape, Promote, Demote, Remove and layout switching (`crates/engine/src/cmd/smartart.rs`). On `.pptx` the outline is written as a real diagram (data, layout, quick style, colours and the cached drawing; `crates/pptx/src/dgm.rs`), and PowerPoint diagrams read their outline from the - data model, so they get the Text Pane too. Still missing: DeckCraft's own layout definitions are - simple (not yet checked in PowerPoint itself); there are six layouts rather than ~200; editing an + data model, so they get the Text Pane too. 15 layouts (bullet and block lists, five process, two cycle and + a radial, hierarchy, two pyramids, two matrices), four SmartArt Styles and Change Colors + (colourful or one accent). Still missing: DeckCraft's own layout definitions are simple (not yet + checked in PowerPoint itself); PowerPoint has ~200 layouts, picture layouts and 3-D styles; editing an imported diagram's outline redraws it with DeckCraft's layout; a diagram with no cached drawing isn't drawn. - **Impact:** a PowerPoint diagram edited in DeckCraft changes look. From f894bb1d172f6165ba6061cb8df1b35a6c97fcab Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:47:15 +0300 Subject: [PATCH 05/17] SmartArt: keep pyramid text inside its band Inset each pyramid band's text past the band's mid width so bullets no longer hang over the sloped sides; capped so the tip band still has room. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 331dabec..c4905a2b 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -563,6 +563,12 @@ fn layout_items(d: &Diagram, area: Xfrm) -> Vec { sh.geom = Geom::Custom { paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], }; + // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. + let inset = ((it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0).min(w * 0.2); + if let Some(b) = &mut sh.text { + b.body.inset_l = Some(inset); + b.body.inset_r = Some(inset); + } paint(d, &mut sh, i, false); out.push((sh, g.clone())); } From 6f19e0bd07102f243876d7abad6a0066fe831137 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:56:36 +0300 Subject: [PATCH 06/17] SmartArt: shrink text to fit, picture layouts, 3-D styles and more colours Each shape's text shrinks so its longest word fits on a line (the pyramid tip no longer breaks words). Three picture layouts (Vertical Picture List, Picture Caption List, Picture Accent Process) with a picture placeholder per node and smartArt.picture to fill it. Five 3-D SmartArt Styles (Polished, Inset, Cartoon, Powder, Brick Scene) drawn with lit bevels, and Colorful Range and Gradient Range colours; all written to and read from the .pptx quickStyle and colors parts. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 264 ++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 45 +++++ crates/model/src/lib.rs | 3 + crates/pptx/src/dgm.rs | 120 ++++++++++++-- crates/ui-egui/src/lib.rs | 22 ++- crates/ui-egui/src/ribbon.rs | 14 ++ 6 files changed, 431 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index c4905a2b..b26d0308 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -23,7 +23,7 @@ pub const MAX_LEVEL: u8 = 8; const MAX_SHAPES: usize = 40; /// DeckCraft's layouts, with the names PowerPoint's gallery gives them. -pub const LAYOUTS: [(&str, &str); 15] = [ +pub const LAYOUTS: [(&str, &str); 18] = [ ("list", "Vertical Bullet List"), ("blockList", "Basic Block List"), ("hBulletList", "Horizontal Bullet List"), @@ -39,19 +39,41 @@ pub const LAYOUTS: [(&str, &str); 15] = [ ("invertedPyramid", "Inverted Pyramid"), ("matrix", "Basic Matrix"), ("titledMatrix", "Titled Matrix"), + ("pictureList", "Vertical Picture List"), + ("pictureCaption", "Picture Caption List"), + ("pictureProcess", "Picture Accent Process"), ]; /// SmartArt Styles. -pub const STYLES: [(&str, &str); 4] = - [("flat", "Simple Fill"), ("subtle", "Subtle Effect"), ("intense", "Intense Effect"), ("outline", "White Outline")]; +pub const STYLES: [(&str, &str); 9] = [ + ("flat", "Simple Fill"), + ("subtle", "Subtle Effect"), + ("intense", "Intense Effect"), + ("outline", "White Outline"), + ("polished", "Polished"), + ("inset", "Inset"), + ("cartoon", "Cartoon"), + ("powder", "Powder"), + ("brick", "Brick Scene"), +]; /// Change Colors. -pub const COLORS: [(&str, &str); 7] = [ +pub const COLORS: [(&str, &str); 17] = [ ("colorful", "Colorful"), + ("range23", "Colorful Range - Accent Colors 2 to 3"), + ("range34", "Colorful Range - Accent Colors 3 to 4"), + ("range45", "Colorful Range - Accent Colors 4 to 5"), + ("range56", "Colorful Range - Accent Colors 5 to 6"), ("accent1", "Accent 1"), ("accent2", "Accent 2"), ("accent3", "Accent 3"), ("accent4", "Accent 4"), ("accent5", "Accent 5"), ("accent6", "Accent 6"), + ("accent1Grad", "Gradient Range - Accent 1"), + ("accent2Grad", "Gradient Range - Accent 2"), + ("accent3Grad", "Gradient Range - Accent 3"), + ("accent4Grad", "Gradient Range - Accent 4"), + ("accent5Grad", "Gradient Range - Accent 5"), + ("accent6Grad", "Gradient Range - Accent 6"), ]; pub fn is_kind(k: &str) -> bool { @@ -103,16 +125,25 @@ pub fn specs() -> Vec { "Change Layout", ["SmartArt Design", "Layouts"], None, - "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess}", has_selection, change_layout ), + cmd!( + "smartArt.picture", + "Insert Picture in SmartArt", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index, data?: base64 | path?: string | reset?: bool} (the node's picture placeholder in picture layouts)", + has_selection, + set_picture + ), cmd!( "smartArt.style", "SmartArt Styles", ["SmartArt Design", "SmartArt Styles"], None, - "{id?, style: flat|subtle|intense|outline}", + "{id?, style: flat|subtle|intense|outline|polished|inset|cartoon|powder|brick}", has_selection, change_style ), @@ -121,7 +152,7 @@ pub fn specs() -> Vec { "Change Colors", ["SmartArt Design", "SmartArt Styles"], None, - "{id?, colors: colorful|accent1|…|accent6}", + "{id?, colors: colorful|range23|range34|range45|range56|accent1|…|accent6|accent1Grad|…|accent6Grad}", has_selection, change_colors ), @@ -135,7 +166,7 @@ pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec usize { /// new node's index. pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { let Some(i) = index.filter(|i| *i < nodes.len()) else { - nodes.push(DiagramNode { text: text.into(), level: 0 }); + nodes.push(DiagramNode { text: text.into(), level: 0, picture: None }); return nodes.len() - 1; }; let lv = nodes[i].level; @@ -183,7 +214,7 @@ pub fn add(nodes: &mut Vec, index: Option, position: &str, t } _ => (subtree_end(nodes, i), lv), }; - nodes.insert(at, DiagramNode { text: text.into(), level }); + nodes.insert(at, DiagramNode { text: text.into(), level, picture: None }); at } @@ -272,19 +303,49 @@ fn slot(i: usize) -> SchemeSlot { /// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN` /// keeps one. fn accent(d: &Diagram, i: usize) -> SchemeSlot { - match d.colors.strip_prefix("accent").and_then(|n| n.parse::().ok()) { + let digit = |s: &str, k: usize| s.chars().nth(k).and_then(|c| c.to_digit(10)).map(|n| n as usize); + if let Some(r) = d.colors.strip_prefix("range") + && let Some(a @ 1..=6) = digit(r, 0) + { + // Colorful Range: alternate between two neighbouring accents. + return slot(a - 1 + i % 2); + } + match d.colors.strip_prefix("accent").and_then(|r| digit(r, 0)) { Some(n @ 1..=6) => slot(n - 1), _ => slot(i), } } +/// Gradient Range: each shape a step darker than the one before (the steps cycle). +fn shade(d: &Diagram, i: usize) -> Option { + d.colors.ends_with("Grad").then(|| ColorTransform::LumMod(100_000 - (i % 4) as i32 * 15_000)) +} + +/// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points) +/// lit by the three-point rig. +fn three_d(bevel: &str, w: f64, h: f64) -> String { + format!( + "", + (w * 12700.0).round() as i64, + (h * 12700.0).round() as i64 + ) +} + +/// Whether `style` is one of the 3-D SmartArt Styles. +pub fn is_3d(style: &str) -> bool { + matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick") +} + /// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet /// boxes of the bullet lists) are a pale tint of the accent with dark text. fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { let a = accent(d, i); - let base = ColorRef::scheme(a); + let base = match shade(d, i) { + Some(t) => ColorRef::scheme(a).with(t), + None => ColorRef::scheme(a), + }; let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000)); - let dark_text = light || d.style == "outline"; + let dark_text = light || matches!(d.style.as_str(), "outline" | "powder"); sh.style = Some(ShapeStyle::accent(a)); match d.style.as_str() { _ if light => { @@ -300,6 +361,23 @@ fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() }); } + "polished" | "inset" | "cartoon" | "powder" | "brick" => { + let (bevel, bw, line, fill) = match d.style.as_str() { + "polished" => ("circle", 12.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 0.75)), base.clone()), + "inset" => ("relaxedInset", 10.0, None, base.clone()), + "cartoon" => ("softRound", 8.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)), base.clone()), + "powder" => ("convex", 14.0, None, tint(40)), + _ => ("slope", 6.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)), base.clone()), + }; + sh.fill = Some(Fill::solid(fill)); + sh.line = Some(line.unwrap_or_else(|| Line { fill: Some(Fill::None), ..Default::default() })); + sh.effects = Some(Effects { + outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)), + bevel: Some(bevel.into()), + raw3d: Some(three_d(bevel, bw, bw * 0.6)), + ..Default::default() + }); + } "intense" => { sh.fill = Some(Fill::Gradient(Gradient { stops: vec![ @@ -403,8 +481,73 @@ pub fn layout(d: &Diagram, area: Xfrm) -> Vec { layout_items(d, area).into_iter().map(|(s, _)| s).collect() } -/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows. +/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows; text is +/// shrunk per shape so its longest word fits on a line. fn layout_items(d: &Diagram, area: Xfrm) -> Vec { + let mut v = layout_raw(d, area); + for (sh, _) in &mut v { + fit_words(sh); + } + v +} + +/// Rough advance of a character as a fraction of the font size (a sans text face). +const CHAR_W: f64 = 0.6; +/// Smallest font scale shrinking to fit goes to. +const MIN_SCALE: f64 = 0.25; + +/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does) so +/// no word is wider than its line and would break mid-word. Overflow in height is left to the +/// body's shrink-on-overflow autofit. +fn fit_words(sh: &mut Shape) { + let Some(w) = sh.xfrm.map(|x| x.w) else { return }; + let Some(body) = &mut sh.text else { return }; + let avail = w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); + let mut scale: f64 = 1.0; + for p in &body.paragraphs { + let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0); + for r in &p.runs { + let size = r.props.size.unwrap_or(18.0); + let word = r.text.split_whitespace().map(|t| t.chars().count()).max().unwrap_or(0) as f64; + let need = word * size * CHAR_W; + if need > 0.0 { + scale = scale.min(line.max(0.0) / need); + } + } + } + let scale = scale.max(MIN_SCALE); + if scale >= 0.999 { + return; + } + let shrink = |v: &mut Option| { + if let Some(x) = v { + *x = (*x * scale * 2.0).floor() / 2.0; + } + }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + shrink(&mut r.props.size); + } + shrink(&mut p.end_props.size); + shrink(&mut p.props.margin_left); + if let Some(i) = &mut p.props.indent { + *i *= scale; + } + } +} + +/// A picture placeholder for node `node`: its picture when it has one, else a pale box. +fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape { + let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }; + paint(d, &mut sh, i, true); + if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) { + sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + sh +} + +fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { let gs = groups(d); let nodes = |g: &[usize]| -> Vec<&DiagramNode> { g.iter().filter_map(|i| d.nodes.get(*i)).collect() }; let n = gs.len().max(1); @@ -564,10 +707,15 @@ fn layout_items(d: &Diagram, area: Xfrm) -> Vec { paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], }; // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. - let inset = ((it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0).min(w * 0.2); + // The tip band is a triangle: its text sits at the wide end, inset to the width there. + let tip = wt.min(wb) < 1e-6; + let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 }; if let Some(b) = &mut sh.text { b.body.inset_l = Some(inset); b.body.inset_r = Some(inset); + if tip { + b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } } paint(d, &mut sh, i, false); out.push((sh, g.clone())); @@ -590,6 +738,46 @@ fn layout_items(d: &Diagram, area: Xfrm) -> Vec { out.push((sh, t.clone())); } } + "pictureList" => { + // Vertical Picture List: a picture square at the left of each row, the text beside it. + let h = area.h / nf; + let bh = h * 0.88; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let s = bh.min(area.w * 0.3); + out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![])); + titled(&mut out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g); + } + } + "pictureCaption" | "pictureProcess" => { + // Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one + // row, arrows between the steps. + let process = d.kind == "pictureProcess"; + let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) }; + let rows = n.div_ceil(cols); + let gap = if process { area.w * 0.05 } else { 0.0 }; + let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; + let h = area.h / rows as f64; + let bw = if process { w } else { w * 0.9 }; + let ch = (h * 0.9).min(bw * 1.1); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0; + let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0; + let y = area.y + r as f64 * h + (h - ch) / 2.0; + let ph = ch * 0.62; + out.push(( + picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]), + vec![], + )); + titled(&mut out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g); + if process && i + 1 < n { + let a = (gap * 0.7).min(ch * 0.2); + arrow(&mut out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i); + } + } + } _ => {} } out @@ -751,7 +939,7 @@ fn index(p: &Value, cmd: &str) -> Result { pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { let kind = if is_kind(kind) { kind } else { "process" }; if nodes.is_empty() { - nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect(); + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None }).collect(); } normalize(&mut nodes); let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; @@ -834,6 +1022,26 @@ fn demote(s: &mut Session, p: &Value) -> Result { edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) } +fn set_picture(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.picture"; + let i = index(p, CMD)?; + let media = if bool_or(p, "reset", false) { + None + } else { + let (name, bytes) = super::insert::media_bytes(p, CMD)?; + let ct = super::insert::content_type(&name, &bytes); + target_group(s, p, CMD)?; + Some(s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?) + }; + edit_diagram(s, p, CMD, |d| { + // A sub-point's picture is its top-level node's: that's the node the placeholder shows. + let top = (0..=i.min(d.nodes.len().saturating_sub(1))).rev().find(|k| d.nodes.get(*k).is_some_and(|n| n.level == 0)).unwrap_or(i); + let n = d.nodes.get_mut(top).ok_or_else(|| bad(CMD, "no such node"))?; + n.picture = media; + Ok(Value::Null) + }) +} + fn change_style(s: &mut Session, p: &Value) -> Result { const CMD: &str = "smartArt.style"; let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string(); @@ -880,7 +1088,7 @@ mod tests { n.iter().map(|x| x.level).collect() } fn mk(levels: &[u8]) -> Vec { - levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l }).collect() + levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None }).collect() } #[test] @@ -934,7 +1142,7 @@ mod tests { fn every_layout_shows_every_node_inside_its_area() { let area = Xfrm::new(50.0, 40.0, 800.0, 400.0); for (kind, _) in LAYOUTS { - for style in ["", "subtle", "intense", "outline"] { + for style in std::iter::once("").chain(STYLES.iter().map(|(k, _)| *k)) { let d = Diagram { kind: kind.into(), nodes: mk(&[0, 1, 0, 0, 1, 0]), @@ -956,4 +1164,24 @@ mod tests { } } } + + #[test] + fn long_words_shrink_to_fit_their_shape() { + let d = Diagram { kind: "pyramid".into(), nodes: nodes_from_lines(["Internationalisation", "Build", "Test"]), ..Default::default() }; + let items = layout_items(&d, Xfrm::new(0.0, 0.0, 600.0, 300.0)); + let size = |k: usize| items[k].0.text.as_ref().and_then(|b| b.paragraphs[0].runs[0].props.size).unwrap_or(0.0); + assert!(size(0) < 20.0 && size(0) >= 20.0 * MIN_SCALE - 0.5, "the tip's long word shrinks: {}", size(0)); + assert!((size(1) - 20.0).abs() < 1e-9, "short words keep their size"); + } + + #[test] + fn colour_variants_pick_accents() { + let d = |c: &str| Diagram { colors: c.into(), ..Default::default() }; + assert_eq!( + (accent(&d("range45"), 0), accent(&d("range45"), 1), accent(&d("range45"), 2)), + (SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent4) + ); + assert_eq!(accent(&d("accent3Grad"), 4), SchemeSlot::Accent3); + assert!(shade(&d("accent3Grad"), 1).is_some() && shade(&d("accent3"), 1).is_none()); + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 64b0942c..f66f84fe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -925,3 +925,48 @@ fn smartart_round_trips_through_pptx_as_a_diagram() { let d = again.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); assert_eq!(d.nodes.last().map(|n| (n.text.as_str(), n.level)), Some(("COO", 1))); } + +#[test] +fn smartart_pictures_3d_styles_and_colour_variants() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "pictureCaption", "items": ["Lakes", "\tBlue", "Hills"]})).unwrap(); + let pics = |s: &Session| -> Vec { + let st = s.doc().unwrap(); + let g = st.doc.slides[1].shapes.iter().find(|x| x.diagram.is_some()).unwrap().clone(); + g.children().iter().filter(|c| c.name == "SmartArt picture").cloned().collect() + }; + assert_eq!(pics(&s).len(), 2, "one picture placeholder per top-level node"); + assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_))))); + // A 1×1 PNG put in through a sub-point lands in its top-level node. + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + s.execute("smartArt.picture", &json!({"index": 1, "name": "p.png", "data": png})).unwrap(); + let p = pics(&s); + assert!(matches!(p[0].fill, Some(deckcraft_model::Fill::Picture(_)))); + assert!(!matches!(p[1].fill, Some(deckcraft_model::Fill::Picture(_)))); + // Text edits keep the picture; reset removes it. + s.execute("smartArt.addNode", &json!({"text": "Woods"})).unwrap(); + assert!(matches!(pics(&s)[0].fill, Some(deckcraft_model::Fill::Picture(_)))); + assert_eq!(pics(&s).len(), 3); + // The picture is written to the .pptx drawing. + let bytes = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert!(back.validate().is_empty()); + assert_eq!(back.slides[1].shapes.iter().find_map(|x| x.diagram.as_ref()).map(|d| d.kind.clone()).as_deref(), Some("pictureCaption")); + s.execute("smartArt.picture", &json!({"index": 0, "reset": true})).unwrap(); + assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_))))); + + // 3-D styles carry a lit bevel; colour variants round trip through .pptx. + s.execute("smartArt.style", &json!({"style": "cartoon"})).unwrap(); + let st = s.doc().unwrap(); + let g = st.doc.slides[1].shapes.iter().find(|x| x.diagram.is_some()).unwrap().clone(); + let raw = g.children().iter().find_map(|c| c.effects.as_ref().and_then(|e| e.raw3d.clone())).expect("3-D XML"); + assert!(raw.contains("scene3d") && raw.contains("softRound")); + for (style, colors) in [("polished", "range34"), ("powder", "accent5Grad"), ("brick", "accent2")] { + s.execute("smartArt.style", &json!({"style": style})).unwrap(); + s.execute("smartArt.colors", &json!({"colors": colors})).unwrap(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap()).unwrap(); + let d = back.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); + assert_eq!((d.style.as_str(), d.colors.as_str()), (style, colors)); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index cfd30ab1..0d1afb9c 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -362,6 +362,9 @@ pub struct Diagram { pub struct DiagramNode { pub text: String, pub level: u8, + /// The picture shown in the node's picture placeholder (picture layouts). + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, } impl Default for Shape { diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index 993c771f..e748da3e 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -28,6 +28,11 @@ pub fn qs_id(style: &str) -> String { "subtle" => "simple3", "intense" => "simple5", "outline" => "simple4", + "polished" => "3d1", + "inset" => "3d2", + "cartoon" => "3d3", + "powder" => "3d4", + "brick" => "3d5", _ => "simple1", }; format!("{QS_BASE}{s}") @@ -38,6 +43,11 @@ pub fn style_for(qs: &str) -> &'static str { match qs.rsplit('/').next().unwrap_or("") { "simple1" | "" => "", "simple4" => "outline", + "3d1" => "polished", + "3d2" => "inset", + "3d3" => "cartoon", + "3d4" => "powder", + "3d5" => "brick", "simple2" | "simple3" => "subtle", _ => "intense", } @@ -45,17 +55,37 @@ pub fn style_for(qs: &str) -> &'static str { /// PowerPoint's colour definition for Change Colors. pub fn cs_id(colors: &str) -> String { - match colors.strip_prefix("accent").and_then(|n| n.parse::().ok()) { - Some(n @ 1..=6) => format!("{CS_BASE}accent{n}_2"), + if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + return format!("{CS_BASE}colorful{a}"); + } + let grad = colors.ends_with("Grad"); + match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => format!("{CS_BASE}accent{n}_{}", if grad { 3 } else { 2 }), _ => format!("{CS_BASE}colorful1"), } } +/// The accents a Change Colors choice fills shapes with, in turn. +fn fill_accents(colors: &str) -> Vec { + if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + return vec![format!("accent{a}"), format!("accent{}", a + 1)]; + } + match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => vec![format!("accent{n}")], + _ => (1..=6).map(|k| format!("accent{k}")).collect(), + } +} + /// Change Colors from a colour definition id: `accentN_…` keeps accent N, anything else is colourful. pub fn colors_for(cs: &str) -> String { let last = cs.rsplit('/').next().unwrap_or(""); - match last.strip_prefix("accent").and_then(|r| r.split('_').next()).and_then(|n| n.parse::().ok()) { - Some(n @ 1..=6) => format!("accent{n}"), + if let Some(a @ 2..=5) = last.strip_prefix("colorful").and_then(|n| n.parse::().ok()) { + return format!("range{a}{}", a + 1); + } + let mut parts = last.strip_prefix("accent").unwrap_or("").split('_'); + match (parts.next().and_then(|n| n.parse::().ok()), parts.next()) { + (Some(n @ 1..=6), Some("3" | "4" | "5")) => format!("accent{n}Grad"), + (Some(n @ 1..=6), _) => format!("accent{n}"), _ => String::new(), } } @@ -79,6 +109,9 @@ pub fn layout_id(kind: &str) -> &'static str { "invertedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid3", "matrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix3", "titledMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix1", + "pictureList" => "urn:microsoft.com/office/officeart/2005/8/layout/vList4", + "pictureCaption" => "urn:microsoft.com/office/officeart/2005/8/layout/pList1", + "pictureProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/pList2", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } @@ -102,6 +135,9 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "invertedPyramid", "matrix", "titledMatrix", + "pictureList", + "pictureCaption", + "pictureProcess", "list", ] { if layout_id == layout_id_of(k) { @@ -138,7 +174,7 @@ fn layout_id_of(kind: &str) -> &'static str { fn category(kind: &str) -> &'static str { match kind { - "process" | "chevron" | "verticalProcess" | "stepUp" => "process", + "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" => "process", "cycle" | "blockCycle" | "radial" => "cycle", "hierarchy" => "hierarchy", "pyramid" | "invertedPyramid" => "pyramid", @@ -288,7 +324,7 @@ pub fn outline(data: &El) -> Vec { if out.len() >= MAX_NODES || !seen.insert(id) { continue; } - out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8) }); + out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None }); if let Some(v) = kids.get(id) { stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); } @@ -312,7 +348,7 @@ fn paragraphs(t: &El) -> String { /// The layout algorithm and node shape of a DeckCraft layout. fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static str) { match kind { - "process" | "stepUp" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), + "process" | "stepUp" | "pictureProcess" | "pictureCaption" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), "cycle" | "radial" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), @@ -450,6 +486,18 @@ pub fn layout_xml(kind: &str) -> Vec { w.finish() } +/// The top bevel (preset, width in EMU) of a 3-D SmartArt Style. +fn bevel(style: &str) -> Option<(&'static str, i64)> { + Some(match style { + "polished" => ("circle", 76200), + "inset" => ("relaxedInset", 63500), + "cartoon" => ("softRound", 50800), + "powder" => ("convex", 101600), + "brick" => ("slope", 50800), + _ => return None, + }) +} + /// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. pub fn style_xml(style: &str) -> Vec { let mut w = W::new(); @@ -457,7 +505,7 @@ pub fn style_xml(style: &str) -> Vec { w.empty("dgm:title", A::new().a("val", "")); w.empty("dgm:desc", A::new().a("val", "")); w.open0("dgm:catLst"); - w.empty("dgm:cat", A::new().a("type", "simple").a("pri", "10100")); + w.empty("dgm:cat", A::new().a("type", if bevel(style).is_some() { "3D" } else { "simple" }).a("pri", "10100")); w.close("dgm:catLst"); w.open0("dgm:scene3d"); w.empty("a:camera", A::new().a("prst", "orthographicFront")); @@ -468,7 +516,14 @@ pub fn style_xml(style: &str) -> Vec { w.empty("a:camera", A::new().a("prst", "orthographicFront")); w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); w.close("dgm:scene3d"); - w.empty0("dgm:sp3d"); + match bevel(style) { + Some((prst, w_emu)) => { + w.open0("dgm:sp3d"); + w.empty("a:bevelT", A::new().a("w", w_emu.to_string()).a("h", (w_emu * 3 / 5).to_string()).a("prst", prst)); + w.close("dgm:sp3d"); + } + None => w.empty0("dgm:sp3d"), + } w.empty("dgm:txPr", A::new()); w.open0("dgm:style"); for (tag, idx) in [("a:lnRef", "2"), ("a:fillRef", "1"), ("a:effectRef", "0")] { @@ -491,19 +546,26 @@ pub fn colors_xml(colors: &str) -> Vec { w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", cs_id(colors))); w.empty("dgm:title", A::new().a("val", "")); w.empty("dgm:desc", A::new().a("val", "")); - let single = colors.starts_with("accent"); + let accents = fill_accents(colors); + let single = accents.len() == 1; + let cat = if single { accents.first().map(String::as_str).unwrap_or("accent1") } else { "colorful" }; w.open0("dgm:catLst"); - w.empty("dgm:cat", A::new().a("type", if single { colors } else { "colorful" }).a("pri", "11200")); + w.empty("dgm:cat", A::new().a("type", cat).a("pri", "11200")); w.close("dgm:catLst"); w.open("dgm:styleLbl", A::new().a("name", "node1")); - if single { + if colors.ends_with("Grad") { + // Gradient Range: the accent shaded darker across the shapes. w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); - w.empty("a:schemeClr", A::new().a("val", colors)); + for k in 0..4 { + w.open("a:schemeClr", A::new().a("val", cat)); + w.empty("a:lumMod", A::new().a("val", (100_000 - k * 15_000).to_string())); + w.close("a:schemeClr"); + } w.close("dgm:fillClrLst"); } else { w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); - for k in 1..=6 { - w.empty("a:schemeClr", A::new().a("val", format!("accent{k}"))); + for a in &accents { + w.empty("a:schemeClr", A::new().a("val", a.as_str())); } w.close("dgm:fillClrLst"); } @@ -524,7 +586,7 @@ mod tests { use super::*; fn nodes(v: &[(&str, u8)]) -> Vec { - v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l }).collect() + v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None }).collect() } #[test] @@ -536,11 +598,35 @@ mod tests { assert_eq!(outline(&doc.root), d.nodes); let s = String::from_utf8(xml).unwrap(); assert!(s.contains("hierarchy1") && s.contains("relId=\"rId9\"")); - for x in [layout_xml("hierarchy"), layout_xml("cycle"), style_xml("subtle"), colors_xml(""), colors_xml("accent3")] { + for x in [ + layout_xml("hierarchy"), + layout_xml("cycle"), + style_xml("subtle"), + colors_xml(""), + colors_xml("accent3"), + style_xml("polished"), + colors_xml("range23"), + colors_xml("accent6Grad"), + layout_xml("pictureList"), + ] { assert!(crate::xml::parse(&x).is_ok()); } } + #[test] + fn style_and_colour_ids_round_trip() { + for st in ["", "subtle", "intense", "outline", "polished", "inset", "cartoon", "powder", "brick"] { + assert_eq!(style_for(&qs_id(st)), st); + } + for c in ["", "range23", "range56", "accent1", "accent4Grad"] { + assert_eq!(colors_for(&cs_id(c)), c); + } + assert!(String::from_utf8(style_xml("cartoon")).unwrap().contains("bevelT")); + for k in ["pictureList", "pictureCaption", "pictureProcess"] { + assert_eq!(kind_for(layout_id(k), ""), k); + } + } + #[test] fn hostile_links_are_bounded() { let xml = br#"A"#; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b2f0e33..0435923c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -173,6 +173,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.preferences", "Preferences…", Some("Cmd+,"), "{}"), ("app.openDialog", "Open…", None, "{purpose?}"), ("app.insertPictureDialog", "Picture from File…", None, "{}"), + ("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"), ("app.insertAudioDialog", "Audio from File…", None, "{}"), ("app.insertVideoDialog", "Video from File…", None, "{}"), ("app.saveAsDialog", "Save As…", None, "{}"), @@ -547,6 +548,12 @@ impl SlideApp { "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), "app.openDialog" => self.pick_and_open(), "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), + "app.smartArtPictureDialog" => { + if self.services.pick_open.is_none() { + return Err("picking a SmartArt picture needs the desktop app".into()); + } + self.pick_and_run("picture", "smartArt.picture", p); + } "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), @@ -591,14 +598,25 @@ impl SlideApp { } fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { + self.pick_and_run(purpose, cmd, &json!({})); + } + + /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. + fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) { if let Some(pick) = self.services.pick_open.as_mut() { if let Some(path) = pick(purpose) { + let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; let r = match self.services.read.as_ref().map(|r| r(&path)) { Some(Ok(bytes)) => { let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - self.session.execute(cmd, &json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&bytes)})) + args["name"] = json!(name); + args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); + self.session.execute(cmd, &args) + } + _ => { + args["path"] = json!(path); + self.session.execute(cmd, &args) } - _ => self.session.execute(cmd, &json!({"path": path})), }; if let Err(e) = r { self.set_status(e.to_string()); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f1ca7499..66706d18 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2354,6 +2354,13 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { run(app, "smartArt.demote", json!({"id": gid, "index": at})); } }); + let pictures = kind.starts_with("picture"); + if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false) + .on_hover_text("Put a picture in the selected node's picture placeholder") + .clicked() + { + run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at})); + } }); group(ui, |ui| { let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); @@ -2369,6 +2376,9 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { let (r, _) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); egui::Popup::menu(&r).show(|ui| { for (k, label) in deckcraft_engine::cmd::smartart::COLORS { + if matches!(k, "range23" | "accent1" | "accent1Grad") { + ui.separator(); + } let on = colors == k || (colors.is_empty() && k == "colorful"); if ui.selectable_label(on, label).clicked() { run(app, "smartArt.colors", json!({"id": gid, "colors": k})); @@ -2378,6 +2388,10 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { let (r, _) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); egui::Popup::menu(&r).show(|ui| { for (k, label) in deckcraft_engine::cmd::smartart::STYLES { + if k == "polished" { + ui.separator(); + ui.label("3-D"); + } let on = style == k || (style.is_empty() && k == "flat"); if ui.selectable_label(on, label).clicked() { run(app, "smartArt.style", json!({"id": gid, "style": k})); From c5a63fc8b7c9d0e58d88b39b38d9996e2f78f83e Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:02:42 +0300 Subject: [PATCH 07/17] SmartArt: node pictures from the data model, Brick Scene camera, four more layouts Node pictures are written on the data model's presentation points (blip through the data part's rels) and read back into the outline on import. Picture Accent Process is hProcess10, not pList2 (checked against PowerPoint's layout list); pList2 is now Horizontal Picture List. Brick Scene looks through an isometric camera. New layouts: Horizontal Picture List, Basic Timeline, Basic Venn, Basic Target. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 116 ++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 7 +- crates/pptx/src/dgm.rs | 96 +++++++++++++++++++++---- crates/pptx/src/read/shapes.rs | 11 +++ crates/pptx/src/write/shapes.rs | 7 +- 5 files changed, 218 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index b26d0308..6a533015 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -23,7 +23,7 @@ pub const MAX_LEVEL: u8 = 8; const MAX_SHAPES: usize = 40; /// DeckCraft's layouts, with the names PowerPoint's gallery gives them. -pub const LAYOUTS: [(&str, &str); 18] = [ +pub const LAYOUTS: [(&str, &str); 22] = [ ("list", "Vertical Bullet List"), ("blockList", "Basic Block List"), ("hBulletList", "Horizontal Bullet List"), @@ -42,6 +42,10 @@ pub const LAYOUTS: [(&str, &str); 18] = [ ("pictureList", "Vertical Picture List"), ("pictureCaption", "Picture Caption List"), ("pictureProcess", "Picture Accent Process"), + ("pictureHList", "Horizontal Picture List"), + ("timeline", "Basic Timeline"), + ("venn", "Basic Venn"), + ("target", "Basic Target"), ]; /// SmartArt Styles. pub const STYLES: [(&str, &str); 9] = [ @@ -125,7 +129,7 @@ pub fn specs() -> Vec { "Change Layout", ["SmartArt Design", "Layouts"], None, - "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target}", has_selection, change_layout ), @@ -323,14 +327,17 @@ fn shade(d: &Diagram, i: usize) -> Option { /// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points) /// lit by the three-point rig. -fn three_d(bevel: &str, w: f64, h: f64) -> String { +fn three_d(bevel: &str, w: f64, h: f64, camera: &str) -> String { format!( - "", + "", (w * 12700.0).round() as i64, (h * 12700.0).round() as i64 ) } +/// Brick Scene's camera: the diagram's shapes seen from above and to the side. +pub const BRICK_CAMERA: &str = "isometricOffAxis2Top"; + /// Whether `style` is one of the 3-D SmartArt Styles. pub fn is_3d(style: &str) -> bool { matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick") @@ -374,7 +381,7 @@ fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { sh.effects = Some(Effects { outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)), bevel: Some(bevel.into()), - raw3d: Some(three_d(bevel, bw, bw * 0.6)), + raw3d: Some(three_d(bevel, bw, bw * 0.6, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })), ..Default::default() }); } @@ -778,6 +785,105 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } } } + "pictureHList" => { + // Horizontal Picture List: a column per node, a big picture over its text. + let w = area.w / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * w + w * 0.04; + let bw = w * 0.92; + let ph = area.h * 0.55; + out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![])); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } + } + "timeline" => { + // Basic Timeline: an arrow across the middle, a dot per node on it, the text above and + // below in turn. + let mid = area.y + area.h / 2.0; + let bar = (area.h * 0.08).max(6.0); + let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + let w = area.w * 0.92 / nf; + for (i, g) in gs.iter().enumerate() { + let cx = area.x + w * (i as f64 + 0.5); + let dot = bar * 1.4; + let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0); + paint(d, &mut dsh, i, false); + out.push((dsh, vec![])); + let th = area.h / 2.0 - bar * 1.6; + let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 }; + let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &nodes(g), true); + if let Some(b) = &mut t.text { + b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top }); + } + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, g.clone())); + } + } + "venn" => { + // Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle). + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = if n == 1 { s } else { s * 0.62 }; + let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 }; + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians(); + let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(50_000)); + } + if let Some(b) = &mut sh.text { + // Text towards the circle's outer side, clear of the overlap. + let p = dc * 0.18; + let (dx, dy) = (a.cos() * p, a.sin() * p); + b.body.inset_l = Some((7.2 + dx).max(2.0)); + b.body.inset_r = Some((7.2 - dx).max(2.0)); + b.body.inset_t = Some((3.6 + dy).max(2.0)); + b.body.inset_b = Some((3.6 - dy).max(2.0)); + } + out.push((sh, g.clone())); + } + } + "target" => { + // Basic Target: nested rings, the outermost first, each ring's text in a box beside it + // with a line from the ring. + let s = area.h.min(area.w * 0.5); + let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0); + let band = s / 2.0 / nf; + for (i, _) in gs.iter().enumerate() { + let dc = s - 2.0 * band * i as f64; + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + let tx = area.x + s + area.w * 0.06; + let rh = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let ty = area.y + rh * (i as f64 + 0.5); + // The point on ring i's band level with the text, else its top. + let ry = cy - s / 2.0 + band * (i as f64 + 0.5); + out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![])); + let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &nodes(g), true); + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + p.props.align = Some(Align::Left); + } + } + out.push((t, g.clone())); + } + } _ => {} } out diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f66f84fe..e75b25a8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -952,7 +952,12 @@ fn smartart_pictures_3d_styles_and_colour_variants() { let bytes = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap(); let back = deckcraft_pptx::import(&bytes).unwrap(); assert!(back.validate().is_empty()); - assert_eq!(back.slides[1].shapes.iter().find_map(|x| x.diagram.as_ref()).map(|d| d.kind.clone()).as_deref(), Some("pictureCaption")); + let bd = back.slides[1].shapes.iter().find_map(|x| x.diagram.as_ref()).unwrap(); + assert_eq!(bd.kind, "pictureCaption"); + // The node's picture comes back from the data model's blip, as its media. + let m = bd.nodes[0].picture.expect("node picture read back"); + assert!(back.media(m).is_some_and(|x| !x.data.is_empty())); + assert!(bd.nodes.iter().skip(1).all(|n| n.picture.is_none())); s.execute("smartArt.picture", &json!({"index": 0, "reset": true})).unwrap(); assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_))))); diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index e748da3e..be5a280e 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -111,7 +111,11 @@ pub fn layout_id(kind: &str) -> &'static str { "titledMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix1", "pictureList" => "urn:microsoft.com/office/officeart/2005/8/layout/vList4", "pictureCaption" => "urn:microsoft.com/office/officeart/2005/8/layout/pList1", - "pictureProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/pList2", + "pictureProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess10", + "pictureHList" => "urn:microsoft.com/office/officeart/2005/8/layout/pList2", + "timeline" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess11", + "venn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn1", + "target" => "urn:microsoft.com/office/officeart/2005/8/layout/target1", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } @@ -138,6 +142,10 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "pictureList", "pictureCaption", "pictureProcess", + "pictureHList", + "timeline", + "venn", + "target", "list", ] { if layout_id == layout_id_of(k) { @@ -145,7 +153,13 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { } } let s = format!("{last} {}", category.to_ascii_lowercase()); - if s.contains("hierarchy") || s.contains("orgchart") { + if s.contains("venn") { + "venn" + } else if s.contains("target") { + "target" + } else if s.contains("timeline") { + "timeline" + } else if s.contains("hierarchy") || s.contains("orgchart") { "hierarchy" } else if s.contains("radial") { "radial" @@ -174,7 +188,8 @@ fn layout_id_of(kind: &str) -> &'static str { fn category(kind: &str) -> &'static str { match kind { - "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" => "process", + "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" => "process", + "venn" | "target" => "relationship", "cycle" | "blockCycle" | "radial" => "cycle", "hierarchy" => "hierarchy", "pyramid" | "invertedPyramid" => "pyramid", @@ -206,8 +221,8 @@ fn text(w: &mut W, t: &str) { } /// `ppt/diagrams/dataN.xml` for the outline. `drawing_rel` is the slide's relationship id of the -/// cached drawing. -pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec { +/// cached drawing; `pics` the data part's relationship ids of node pictures, by node index. +pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, String)]) -> Vec { let lo = if d.layout_id.is_empty() { layout_id(&d.kind).to_string() } else { d.layout_id.clone() }; let mut w = W::new(); w.open("dgm:dataModel", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R)); @@ -245,6 +260,24 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec { w.close("dgm:pt"); } } + // A node's picture sits on the presentation point of its picture placeholder, as PowerPoint + // stores it (`presAssocID` names the node, the blip is a relationship of the data part). + for (k, rid) in pics { + if *k >= d.nodes.len() { + continue; + } + w.open("dgm:pt", A::new().a("modelId", guid(seed, 0x20_0000 + k)).a("type", "pres")); + w.empty("dgm:prSet", A::new().a("presAssocID", node(*k)).a("presName", "Image").a("presStyleLbl", "fgImgPlace1")); + w.open0("dgm:spPr"); + w.open0("a:blipFill"); + w.empty("a:blip", A::new().a("r:embed", rid.as_str())); + w.open0("a:stretch"); + w.empty0("a:fillRect"); + w.close("a:stretch"); + w.close("a:blipFill"); + w.close("dgm:spPr"); + w.close("dgm:pt"); + } w.close("dgm:ptLst"); w.open0("dgm:cxnLst"); let mut stack: Vec<(u8, String)> = vec![]; @@ -286,6 +319,31 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec { /// The outline of a data part: the `parOf` tree under the document point, depth first. pub fn outline(data: &El) -> Vec { + outline_ids(data).into_iter().map(|(_, n)| n).collect() +} + +/// The relationship ids (of the data part) of node pictures, by node index in [`outline`]: the +/// blip fill of a presentation point associated with the node. +pub fn node_pictures(data: &El) -> Vec<(usize, String)> { + let ids: HashMap = outline_ids(data).into_iter().enumerate().map(|(k, (id, _))| (id, k)).collect(); + let Some(pts) = data.child("ptLst") else { return vec![] }; + let mut out: Vec<(usize, String)> = vec![]; + for pt in pts.children_named("pt").take(MAX_NODES * 8) { + if pt.attr("type") != Some("pres") { + continue; + } + let Some(k) = pt.child("prSet").and_then(|p| p.attr("presAssocID")).and_then(|a| ids.get(a)) else { continue }; + let Some(rid) = pt.child("spPr").and_then(|s| s.child("blipFill")).and_then(|b| b.child("blip")).and_then(|b| b.attr("r:embed")) else { + continue; + }; + if !out.iter().any(|(j, _)| j == k) { + out.push((*k, rid.to_string())); + } + } + out +} + +fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { let Some(pts) = data.child("ptLst") else { return vec![] }; let mut texts: HashMap<&str, String> = HashMap::new(); let mut doc: Option<&str> = None; @@ -324,7 +382,7 @@ pub fn outline(data: &El) -> Vec { if out.len() >= MAX_NODES || !seen.insert(id) { continue; } - out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None }); + out.push((id.to_string(), DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None })); if let Some(v) = kids.get(id) { stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); } @@ -348,10 +406,12 @@ fn paragraphs(t: &El) -> String { /// The layout algorithm and node shape of a DeckCraft layout. fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static str) { match kind { - "process" | "stepUp" | "pictureProcess" | "pictureCaption" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), + "process" | "stepUp" | "pictureProcess" | "pictureCaption" | "pictureHList" | "timeline" => { + ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect") + } "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), - "cycle" | "radial" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), + "cycle" | "radial" | "venn" | "target" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), "blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"), "pyramid" | "invertedPyramid" => { ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") @@ -500,6 +560,8 @@ fn bevel(style: &str) -> Option<(&'static str, i64)> { /// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. pub fn style_xml(style: &str) -> Vec { + // Brick Scene looks at the diagram from above and to the side. + let camera = if style == "brick" { "isometricOffAxis2Top" } else { "orthographicFront" }; let mut w = W::new(); w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", qs_id(style))); w.empty("dgm:title", A::new().a("val", "")); @@ -508,12 +570,12 @@ pub fn style_xml(style: &str) -> Vec { w.empty("dgm:cat", A::new().a("type", if bevel(style).is_some() { "3D" } else { "simple" }).a("pri", "10100")); w.close("dgm:catLst"); w.open0("dgm:scene3d"); - w.empty("a:camera", A::new().a("prst", "orthographicFront")); + w.empty("a:camera", A::new().a("prst", camera)); w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); w.close("dgm:scene3d"); w.open("dgm:styleLbl", A::new().a("name", "node1")); w.open0("dgm:scene3d"); - w.empty("a:camera", A::new().a("prst", "orthographicFront")); + w.empty("a:camera", A::new().a("prst", camera)); w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); w.close("dgm:scene3d"); match bevel(style) { @@ -593,7 +655,7 @@ mod tests { fn outline_survives_the_data_model() { let d = Diagram { kind: "hierarchy".into(), nodes: nodes(&[("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1), ("Board", 0)]), ..Default::default() }; - let xml = data_xml(&d, 7, "rId9"); + let xml = data_xml(&d, 7, "rId9", &[]); let doc = crate::xml::parse(&xml).unwrap(); assert_eq!(outline(&doc.root), d.nodes); let s = String::from_utf8(xml).unwrap(); @@ -613,6 +675,14 @@ mod tests { } } + #[test] + fn node_pictures_come_from_presentation_points() { + let d = Diagram { kind: "pictureList".into(), nodes: nodes(&[("A", 0), ("a", 1), ("B", 0)]), ..Default::default() }; + let xml = data_xml(&d, 3, "rId1", &[(2, "rId7".into()), (9, "rId8".into())]); + let doc = crate::xml::parse(&xml).unwrap(); + assert_eq!(node_pictures(&doc.root), vec![(2, "rId7".to_string())]); + } + #[test] fn style_and_colour_ids_round_trip() { for st in ["", "subtle", "intense", "outline", "polished", "inset", "cartoon", "powder", "brick"] { @@ -622,7 +692,9 @@ mod tests { assert_eq!(colors_for(&cs_id(c)), c); } assert!(String::from_utf8(style_xml("cartoon")).unwrap().contains("bevelT")); - for k in ["pictureList", "pictureCaption", "pictureProcess"] { + assert!(layout_id("pictureProcess").ends_with("/hProcess10"), "Picture Accent Process"); + assert!(layout_id("pictureHList").ends_with("/pList2"), "Horizontal Picture List"); + for k in ["pictureList", "pictureCaption", "pictureProcess", "pictureHList", "timeline", "venn", "target"] { assert_eq!(kind_for(layout_id(k), ""), k); } } diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 34d7bbc1..e9ea5ffb 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -433,6 +433,17 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> let x = frame.xfrm.unwrap_or_default(); g.kind = ShapeKind::Group { children, child: Xfrm::new(0.0, 0.0, x.w, x.h) }; g.diagram = diagram_outline(imp, part, rel_ids, &data.root); + // Node pictures, from the blips of the data model's presentation points. + if let Some(d) = &mut g.diagram { + let rels = imp.pkg.rels(&dm.target); + for (k, rid) in crate::dgm::node_pictures(&data.root) { + let Some(target) = rels.get(&rid).filter(|r| !r.external).map(|r| r.target.clone()) else { continue }; + let media = imp.media_for(&target); + if let Some(n) = d.nodes.get_mut(k) { + n.picture = media; + } + } + } Some(g) } diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 26cb67de..536ecfdd 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -470,7 +470,12 @@ fn diagram(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, d: &dec let rel = |o: &mut Out, kind: &str, part: &str| o.rels.add_new(kind, &crate::opc::relative(&o.name, part), false); let drawing_rel = rel(o, dgm::RT_DRAWING, &dname); let data = name("data"); - x.pkg.xml_part(&data, dgm::CT_DATA, dgm::data_xml(d, s.id.0, &drawing_rel)); + // Node pictures: on the data model's presentation points, through the data part's rels. + let mut mout = Out::new(&data); + let pics: Vec<(usize, String)> = + d.nodes.iter().enumerate().filter_map(|(k, n)| n.picture.and_then(|m| x.media_rel(&mut mout, m, "image")).map(|r| (k, r))).collect(); + x.pkg.xml_part(&data, dgm::CT_DATA, dgm::data_xml(d, s.id.0, &drawing_rel, &pics)); + x.pkg.rels(&data, &mout.rels); let dm = rel(o, "diagramData", &data); let layout = name("layout"); let lo_xml = d.layout_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::layout_xml(&d.kind)); From 00dd069678296a18c88efc3b5b0ee1492ac6c8e6 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:07:51 +0300 Subject: [PATCH 08/17] SmartArt: six more layouts, clear Venn labels, read back older Picture Accent Process files Text Cycle, Continuous Cycle, Horizontal Hierarchy, Grid Matrix, Gear and Funnel. Venn labels sit further out, clear of the overlaps. Files where Picture Accent Process was written as pList2 (with DeckCraft's process-category layout part) import as Picture Accent Process again. Round shapes' text shrinks to the inscribed width. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 149 +++++++++++++++++++++++++++--- crates/pptx/src/dgm.rs | 58 ++++++++++-- 2 files changed, 185 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 6a533015..aa76b6e7 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -23,7 +23,7 @@ pub const MAX_LEVEL: u8 = 8; const MAX_SHAPES: usize = 40; /// DeckCraft's layouts, with the names PowerPoint's gallery gives them. -pub const LAYOUTS: [(&str, &str); 22] = [ +pub const LAYOUTS: [(&str, &str); 28] = [ ("list", "Vertical Bullet List"), ("blockList", "Basic Block List"), ("hBulletList", "Horizontal Bullet List"), @@ -46,6 +46,12 @@ pub const LAYOUTS: [(&str, &str); 22] = [ ("timeline", "Basic Timeline"), ("venn", "Basic Venn"), ("target", "Basic Target"), + ("textCycle", "Text Cycle"), + ("continuousCycle", "Continuous Cycle"), + ("horizontalHierarchy", "Horizontal Hierarchy"), + ("gridMatrix", "Grid Matrix"), + ("gear", "Gear"), + ("funnel", "Funnel"), ]; /// SmartArt Styles. pub const STYLES: [(&str, &str); 9] = [ @@ -129,7 +135,7 @@ pub fn specs() -> Vec { "Change Layout", ["SmartArt Design", "Layouts"], None, - "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel}", has_selection, change_layout ), @@ -508,8 +514,13 @@ const MIN_SCALE: f64 = 0.25; /// body's shrink-on-overflow autofit. fn fit_words(sh: &mut Shape) { let Some(w) = sh.xfrm.map(|x| x.w) else { return }; + // Round shapes keep their text in the inscribed rectangle (about 71% of the width). + let text_w = match sh.geom.preset_name() { + Some("ellipse" | "gear6" | "gear9") => w * 0.71, + _ => w, + }; let Some(body) = &mut sh.text else { return }; - let avail = w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); + let avail = text_w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); let mut scale: f64 = 1.0; for p in &body.paragraphs { let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0); @@ -572,7 +583,8 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { out.push((sh, vec![])); }; match d.kind.as_str() { - "hierarchy" => return hierarchy(d, area), + "hierarchy" => return hierarchy(d, area, false), + "horizontalHierarchy" => return hierarchy(d, area, true), "list" | "hBulletList" => { // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. let vertical = d.kind == "list"; @@ -785,6 +797,108 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } } } + "textCycle" | "continuousCycle" => { + // Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on + // a ring. + let text_only = d.kind == "textCycle"; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + if !text_only { + let ring = r * 2.0 + dd * 0.3; + let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0); + sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] }; + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = (dd * 1.35, dd * 0.7); + let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &nodes(g), true); + paint(d, &mut sh, i, false); + if text_only { + sh.fill = Some(Fill::None); + sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + sh.effects = None; + let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000)); + if let Some(b) = &mut sh.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.3).max(6.0); + arrow( + &mut out, + Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), + (i as f64 + 0.5) / nf * 360.0, + i, + ); + } + } + out.push((sh, g.clone())); + } + } + "gridMatrix" => { + // Grid Matrix: four quadrants on crossed axes. + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let t = s * 0.06; + for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] + { + let mut sh = deco(preset, x, 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + let q = s / 2.0 - t * 1.2; + for (i, g) in gs.iter().take(4).enumerate() { + let (c, r) = ((i % 2) as f64, (i / 2) as f64); + let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q); + titled(&mut out, "rect", x, i, g); + } + } + "gear" => { + // Gear: up to three interlocking gears, the first the biggest. + let s = area.h.min(area.w * 0.62); + let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0); + let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")]; + for (i, g) in gs.iter().take(3).enumerate() { + let (fx, fy, fs, preset) = spots[i]; + let z = s * fs; + titled(&mut out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g); + } + } + "funnel" => { + // Funnel: the ideas poured in at the top (up to three), the last one coming out below. + let s = area.h.min(area.w); + let (cx, y0) = (area.x + area.w / 2.0, area.y); + let fw = s * 0.8; + let fh = s * 0.62; + let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0); + paint(d, &mut f, 0, true); + out.push((f, vec![])); + let (inside, last) = match gs.split_last() { + Some((l, r)) if !r.is_empty() => (r, Some(l)), + _ => (&gs[..], None), + }; + let k = inside.len().min(3); + let dc = fw * 0.27; + let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)]; + for (i, g) in inside.iter().take(3).enumerate() { + let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] }; + titled(&mut out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g); + } + if let Some(g) = last { + let a = s * 0.12; + let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0); + paint(d, &mut ar, k, true); + out.push((ar, vec![])); + titled(&mut out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g); + } + } "pictureHList" => { // Horizontal Picture List: a column per node, a big picture over its text. let w = area.w / nf; @@ -843,7 +957,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } if let Some(b) = &mut sh.text { // Text towards the circle's outer side, clear of the overlap. - let p = dc * 0.18; + let p = dc * 0.27; let (dx, dy) = (a.cos() * p, a.sin() * p); b.body.inset_l = Some((7.2 + dx).max(2.0)); b.body.inset_r = Some((7.2 - dx).max(2.0)); @@ -890,7 +1004,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } /// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. -fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { +fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec { let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; if nodes.is_empty() { return vec![]; @@ -922,9 +1036,11 @@ fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { .collect(); let rows = depth.iter().max().copied().unwrap_or(0) + 1; let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); - let cell_w = area.w / total as f64; - let row_h = area.h / rows as f64; - let (bw, bh) = (cell_w * 0.85, row_h * 0.6); + // Horizontal Hierarchy: the same tree turned a quarter, levels from left to right. + let (span, across) = if horizontal { (area.h, area.w) } else { (area.w, area.h) }; + let cell_w = span / total as f64; + let row_h = across / rows as f64; + let (bw, bh) = if horizontal { (cell_w * 0.8, row_h * 0.7) } else { (cell_w * 0.85, row_h * 0.6) }; // Left edge (in leaves) of each node's span. let mut left = vec![0usize; nodes.len()]; let mut next_root = 0; @@ -949,8 +1065,9 @@ fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { } let boxes: Vec = (0..nodes.len()) .map(|i| { - let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; - Xfrm::new(cx - bw / 2.0, area.y + depth[i] as f64 * row_h + (row_h - bh) / 2.0, bw, bh) + let c = (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + let r = depth[i] as f64 * row_h + (row_h - bh) / 2.0; + if horizontal { Xfrm::new(area.x + r, area.y + c - bw / 2.0, bh, bw) } else { Xfrm::new(area.x + c - bw / 2.0, area.y + r, bw, bh) } }) .collect(); let mut out: Vec = nodes @@ -965,7 +1082,12 @@ fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { for i in 0..nodes.len() { if let Some(p) = parent[i] { let (a, b) = (boxes[p], boxes[i]); - out.push((line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)), vec![])); + let (p, q) = if horizontal { + ((a.x + a.w, a.y + a.h / 2.0), (b.x, b.y + b.h / 2.0)) + } else { + ((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)) + }; + out.push((line_shape(p, q), vec![])); } } out @@ -1259,7 +1381,8 @@ mod tests { let items = layout_items(&d, area); let mut shown: Vec = items.iter().flat_map(|(_, m)| m.clone()).collect(); shown.sort_unstable(); - let want: Vec = (0..6).collect(); + // Gear draws three gears, as PowerPoint does: the fourth group isn't shown. + let want: Vec = (0..if kind == "gear" { 5 } else { 6 }).collect(); assert_eq!(shown, want, "{kind}"); for (sh, _) in &items { let x = sh.xfrm.unwrap_or_default(); diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index be5a280e..eff93b10 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -116,6 +116,12 @@ pub fn layout_id(kind: &str) -> &'static str { "timeline" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess11", "venn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn1", "target" => "urn:microsoft.com/office/officeart/2005/8/layout/target1", + "textCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle1", + "continuousCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle3", + "horizontalHierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy2", + "gridMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix2", + "gear" => "urn:microsoft.com/office/officeart/2005/8/layout/gear1", + "funnel" => "urn:microsoft.com/office/officeart/2005/8/layout/funnel1", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } @@ -123,6 +129,11 @@ pub fn layout_id(kind: &str) -> &'static str { /// The DeckCraft layout closest to a PowerPoint layout (by its id and category). pub fn kind_for(layout_id: &str, category: &str) -> &'static str { let last = layout_id.rsplit('/').next().unwrap_or("").to_ascii_lowercase(); + // DeckCraft before Picture Accent Process got its own id wrote it as pList2 (Horizontal + // Picture List) with its own layout part in the process category; PowerPoint's pList2 is a list. + if last == "plist2" && category.eq_ignore_ascii_case("process") { + return "pictureProcess"; + } // Exact ids first: the layouts DeckCraft writes, and their closest siblings. for k in [ "blockList", @@ -146,6 +157,12 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "timeline", "venn", "target", + "textCycle", + "continuousCycle", + "horizontalHierarchy", + "gridMatrix", + "gear", + "funnel", "list", ] { if layout_id == layout_id_of(k) { @@ -153,7 +170,11 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { } } let s = format!("{last} {}", category.to_ascii_lowercase()); - if s.contains("venn") { + if s.contains("gear") { + "gear" + } else if s.contains("funnel") { + "funnel" + } else if s.contains("venn") { "venn" } else if s.contains("target") { "target" @@ -188,12 +209,12 @@ fn layout_id_of(kind: &str) -> &'static str { fn category(kind: &str) -> &'static str { match kind { - "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" => "process", + "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" | "gear" | "funnel" => "process", "venn" | "target" => "relationship", - "cycle" | "blockCycle" | "radial" => "cycle", - "hierarchy" => "hierarchy", + "cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" => "cycle", + "hierarchy" | "horizontalHierarchy" => "hierarchy", "pyramid" | "invertedPyramid" => "pyramid", - "matrix" | "titledMatrix" => "matrix", + "matrix" | "titledMatrix" | "gridMatrix" => "matrix", _ => "list", } } @@ -411,12 +432,14 @@ fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static } "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), - "cycle" | "radial" | "venn" | "target" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), + "cycle" | "radial" | "venn" | "target" | "textCycle" | "continuousCycle" | "gear" => { + ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse") + } "blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"), "pyramid" | "invertedPyramid" => { ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") } - "matrix" | "titledMatrix" | "blockList" => ( + "matrix" | "titledMatrix" | "gridMatrix" | "blockList" => ( "snake", vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], if kind == "blockList" { "rect" } else { "roundRect" }, @@ -479,7 +502,7 @@ pub fn layout_xml(kind: &str) -> Vec { w.empty0("dgm:dir"); w.empty("dgm:resizeHandles", A::new().a("val", "exact")); w.close("dgm:varLst"); - if kind == "hierarchy" { + if matches!(kind, "hierarchy" | "horizontalHierarchy") { // Each node over the row of its children, recursively. w.empty("dgm:alg", A::new().a("type", "hierChild")); w.open("dgm:shape", A::new().a("r:blip", "")); @@ -694,7 +717,24 @@ mod tests { assert!(String::from_utf8(style_xml("cartoon")).unwrap().contains("bevelT")); assert!(layout_id("pictureProcess").ends_with("/hProcess10"), "Picture Accent Process"); assert!(layout_id("pictureHList").ends_with("/pList2"), "Horizontal Picture List"); - for k in ["pictureList", "pictureCaption", "pictureProcess", "pictureHList", "timeline", "venn", "target"] { + // Picture Accent Process as an earlier DeckCraft wrote it (pList2, process category). + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/pList2", "process"), "pictureProcess"); + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/pList2", "list"), "pictureHList"); + for k in [ + "pictureList", + "pictureCaption", + "pictureProcess", + "pictureHList", + "timeline", + "venn", + "target", + "textCycle", + "continuousCycle", + "horizontalHierarchy", + "gridMatrix", + "gear", + "funnel", + ] { assert_eq!(kind_for(layout_id(k), ""), k); } } From b4c03f0a75c2b635903e20c304bb4abbcd15b8b1 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:16:40 +0300 Subject: [PATCH 09/17] SmartArt: organisation charts with assistants, segmented and multidirectional cycles, cycle matrix, segmented pyramid, pie Organization Chart and Half Circle Organization Chart with elbow connectors; assistant nodes (Add Assistant, written as asst points and read back) sit beside their parent on a row of their own. Segmented Cycle, Multidirectional Cycle, Cycle Matrix, Segmented Pyramid and Basic Pie. Venn labels move further out so the third circle's no longer runs into the overlap. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 308 +++++++++++++++++++++++++++++- crates/model/src/lib.rs | 3 + crates/pptx/src/dgm.rs | 84 ++++++-- crates/ui-egui/src/ribbon.rs | 10 +- 4 files changed, 379 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index aa76b6e7..cd3534be 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -23,7 +23,7 @@ pub const MAX_LEVEL: u8 = 8; const MAX_SHAPES: usize = 40; /// DeckCraft's layouts, with the names PowerPoint's gallery gives them. -pub const LAYOUTS: [(&str, &str); 28] = [ +pub const LAYOUTS: [(&str, &str); 35] = [ ("list", "Vertical Bullet List"), ("blockList", "Basic Block List"), ("hBulletList", "Horizontal Bullet List"), @@ -52,6 +52,13 @@ pub const LAYOUTS: [(&str, &str); 28] = [ ("gridMatrix", "Grid Matrix"), ("gear", "Gear"), ("funnel", "Funnel"), + ("segmentedCycle", "Segmented Cycle"), + ("multidirectionalCycle", "Multidirectional Cycle"), + ("orgChart", "Organization Chart"), + ("halfCircleOrgChart", "Half Circle Organization Chart"), + ("cycleMatrix", "Cycle Matrix"), + ("segmentedPyramid", "Segmented Pyramid"), + ("pie", "Basic Pie"), ]; /// SmartArt Styles. pub const STYLES: [(&str, &str); 9] = [ @@ -115,7 +122,7 @@ pub fn specs() -> Vec { "Add Shape", ["SmartArt Design", "Create Graphic"], None, - "{id?, index?, position?: after|before|below|above, text?} → {index} (default: after the last node)", + "{id?, index?, position?: after|before|below|above|assistant, text?} → {index} (default: after the last node)", has_selection, add_node ), @@ -135,7 +142,7 @@ pub fn specs() -> Vec { "Change Layout", ["SmartArt Design", "Layouts"], None, - "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie}", has_selection, change_layout ), @@ -176,7 +183,7 @@ pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec usize { /// new node's index. pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { let Some(i) = index.filter(|i| *i < nodes.len()) else { - nodes.push(DiagramNode { text: text.into(), level: 0, picture: None }); + nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false }); return nodes.len() - 1; }; let lv = nodes[i].level; @@ -222,9 +229,10 @@ pub fn add(nodes: &mut Vec, index: Option, position: &str, t } (i, lv) } + "assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)), _ => (subtree_end(nodes, i), lv), }; - nodes.insert(at, DiagramNode { text: text.into(), level, picture: None }); + nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant" }); at } @@ -585,6 +593,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { match d.kind.as_str() { "hierarchy" => return hierarchy(d, area, false), "horizontalHierarchy" => return hierarchy(d, area, true), + "orgChart" | "halfCircleOrgChart" => return org_chart(d, area, d.kind == "halfCircleOrgChart"), "list" | "hBulletList" => { // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. let vertical = d.kind == "list"; @@ -842,6 +851,145 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { out.push((sh, g.clone())); } } + "segmentedCycle" | "pie" => { + // Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges. + // The text sits in a clear box over each segment's middle. + let ring = d.kind == "segmentedCycle"; + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let gap = if ring { 2.0 } else { 0.0 }; + for (i, g) in gs.iter().enumerate() { + // Angles clockwise from 3 o'clock, starting at the top. + let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0; + let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0; + let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0); + let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round(); + // blockArc runs from its first angle to its second clockwise; so does pie. + sh.geom = Geom::Preset { + name: if ring { "blockArc" } else { "pie" }.into(), + adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] }, + }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let m = ((a0 + a1) / 2.0).to_radians(); + let rr = if ring { s * 0.4375 } else { s * 0.3 }; + let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9); + let th = s * 0.16; + let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } + "multidirectionalCycle" => { + // Multidirectional Cycle: nodes around a circle, double-headed arrows between them. + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + titled(&mut out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g); + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.5).max(8.0); + let mut sh = deco( + "leftRightArrow", + Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), + (i as f64 + 0.5) / nf * 360.0, + ); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + } + } + } + "cycleMatrix" => { + // Cycle Matrix: four quarters of a circle in the middle with the top-level text, each + // quarter's sub-points in a box at its corner. + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let c = s * 0.56; + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let b = s * 0.4; + let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b); + if g.len() > 1 { + let mut sh = text_shape("roundRect", bx, &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(body) = &mut sh.text { + // Text in the box's outer part, clear of the circle. + body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 }); + body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 }); + body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + out.push((sh, g[1..].to_vec())); + } + } + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5; + let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0); + sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let q = c * 0.3; + let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &nodes(&g[..1]), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g[..1].to_vec())); + } + } + "segmentedPyramid" => { + // Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node, + // every other one upside down. + let rows = (nf.sqrt().ceil() as usize).max(1); + let s = area.h.min(area.w / 1.15); + let pw = s * 1.15; + let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0); + let (tw, th) = (pw / rows as f64, s / rows as f64); + let mut k = 0; + 'rows: for r in 0..rows { + let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0; + for c in 0..(2 * r + 1) { + let Some(g) = gs.get(k) else { break 'rows }; + let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th); + let mut sh = text_shape("rect", x, &nodes(g), true); + // Drawn as a polygon (not a flipped preset) so upside-down text stays upright. + let path = if c % 2 == 1 { + format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0) + } else { + format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0) + }; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { + w: tw, + h: th, + d: path, + fill: deckcraft_geom::preset::FillMode::Norm, + stroke: true, + }], + }; + if let Some(b) = &mut sh.text { + // The text in the triangle's wide half. + b.body.inset_l = Some(tw * 0.22); + b.body.inset_r = Some(tw * 0.22); + if c % 2 == 1 { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + b.body.inset_b = Some(th * 0.35); + } else { + b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); + b.body.inset_t = Some(th * 0.35); + } + } + paint(d, &mut sh, k, false); + out.push((sh, g.clone())); + k += 1; + } + } + } "gridMatrix" => { // Grid Matrix: four quadrants on crossed axes. let s = area.h.min(area.w); @@ -957,7 +1105,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } if let Some(b) = &mut sh.text { // Text towards the circle's outer side, clear of the overlap. - let p = dc * 0.27; + let p = dc * 0.36; let (dx, dy) = (a.cos() * p, a.sin() * p); b.body.inset_l = Some((7.2 + dx).max(2.0)); b.body.inset_r = Some((7.2 - dx).max(2.0)); @@ -1003,6 +1151,135 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { out } +/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on +/// a row of their own between the parent and its other children. `half_circle` draws each node as +/// its text in an open half ring (Half Circle Organization Chart). +fn org_chart(d: &Diagram, area: Xfrm, half_circle: bool) -> Vec { + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let asst = |i: usize| nodes[i].assistant && parent[i].is_some(); + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + // Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent. + let mut slot = vec![0usize; nodes.len()]; + for i in 0..nodes.len() { + slot[i] = match parent[i] { + None => 0, + Some(p) if asst(i) => slot[p] + 1, + Some(p) => slot[p] + 2, + }; + } + let mut used: Vec = slot.clone(); + used.sort_unstable(); + used.dedup(); + let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0); + let rows = used.len().max(1); + let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); + let cell_w = area.w / total as f64; + let row_h = area.h / rows as f64; + let (bw, bh) = ((cell_w * 0.85).min(area.w * 0.3), row_h * 0.62); + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + if asst(i) { + continue; + } + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let mut side = vec![0usize; nodes.len()]; + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0; + match parent[i] { + Some(p) if asst(i) => { + // Assistants alternate right and left of the parent's trunk. + let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count(); + side[i] = k; + let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w; + let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1; + let x = if k % 2 == 0 { pc + off } else { pc - off - bw }; + Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh) + } + _ => { + let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + Xfrm::new(cx - bw / 2.0, y, bw, bh) + } + } + }) + .collect(); + let mut out: Vec = vec![]; + for i in 0..nodes.len() { + let Some(p) = parent[i] else { continue }; + let (a, b) = (boxes[p], boxes[i]); + let pc = a.x + a.w / 2.0; + if asst(i) { + let my = b.y + b.h / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![])); + let ex = if b.x > pc { b.x } else { b.x + b.w }; + out.push((line_shape((pc, my), (ex, my)), vec![])); + } else { + let mid = b.y - (row_h - bh) / 2.0; + let cc = b.x + b.w / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![])); + out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![])); + out.push((line_shape((cc, mid), (cc, b.y)), vec![])); + } + } + let _ = side; + for (i, n) in nodes.iter().enumerate() { + let depth = slot[i] / 2; + if half_circle { + let s = boxes[i].h.min(boxes[i].w); + let x = boxes[i]; + let mut arc = deco("blockArc", Xfrm::new(x.x + (x.w - s) / 2.0, x.y, s, s), 0.0); + arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![180.0 * 60_000.0, 0.0, 8_000.0] }; + paint(d, &mut arc, depth, false); + out.push((arc, vec![])); + let mut t = text_shape("rect", x, &[n], true); + paint(d, &mut t, depth, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, vec![i])); + } else { + let mut sh = text_shape("rect", boxes[i], &[n], true); + paint(d, &mut sh, depth, false); + out.push((sh, vec![i])); + } + } + out +} + /// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec { let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; @@ -1167,7 +1444,7 @@ fn index(p: &Value, cmd: &str) -> Result { pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { let kind = if is_kind(kind) { kind } else { "process" }; if nodes.is_empty() { - nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None }).collect(); + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false }).collect(); } normalize(&mut nodes); let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; @@ -1316,7 +1593,7 @@ mod tests { n.iter().map(|x| x.level).collect() } fn mk(levels: &[u8]) -> Vec { - levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None }).collect() + levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None, assistant: false }).collect() } #[test] @@ -1413,4 +1690,17 @@ mod tests { assert_eq!(accent(&d("accent3Grad"), 4), SchemeSlot::Accent3); assert!(shade(&d("accent3Grad"), 1).is_some() && shade(&d("accent3"), 1).is_none()); } + + #[test] + fn assistants_sit_beside_their_parent() { + let mut v = mk(&[0, 1, 1]); + let a = add(&mut v, Some(0), "assistant", "Aide"); + assert_eq!((a, v[1].level, v[1].assistant), (1, 1, true)); + let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() }; + let items = layout_items(&d, Xfrm::new(0.0, 0.0, 600.0, 400.0)); + let boxed = |k: usize| items.iter().find(|(_, m)| m == &vec![k]).and_then(|(s, _)| s.xfrm).unwrap_or_default(); + let (ceo, aide, kid) = (boxed(0), boxed(1), boxed(2)); + assert!(aide.y > ceo.y && aide.y < kid.y, "the assistant's row is between its parent and the children"); + assert!(aide.x > ceo.x, "beside the parent's trunk"); + } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 0d1afb9c..2d836397 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -365,6 +365,9 @@ pub struct DiagramNode { /// The picture shown in the node's picture placeholder (picture layouts). #[serde(skip_serializing_if = "Option::is_none")] pub picture: Option, + /// An assistant (organisation charts): drawn beside its parent, off the row of its siblings. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub assistant: bool, } impl Default for Shape { diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index eff93b10..90d1bedc 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -122,6 +122,13 @@ pub fn layout_id(kind: &str) -> &'static str { "gridMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix2", "gear" => "urn:microsoft.com/office/officeart/2005/8/layout/gear1", "funnel" => "urn:microsoft.com/office/officeart/2005/8/layout/funnel1", + "segmentedCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle8", + "multidirectionalCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle7", + "orgChart" => "urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", + "halfCircleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/HalfCircleOrganizationChart", + "cycleMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle4", + "segmentedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid4", + "pie" => "urn:microsoft.com/office/officeart/2005/8/layout/chart3", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } @@ -163,6 +170,13 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "gridMatrix", "gear", "funnel", + "segmentedCycle", + "multidirectionalCycle", + "orgChart", + "halfCircleOrgChart", + "cycleMatrix", + "segmentedPyramid", + "pie", "list", ] { if layout_id == layout_id_of(k) { @@ -180,7 +194,9 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "target" } else if s.contains("timeline") { "timeline" - } else if s.contains("hierarchy") || s.contains("orgchart") { + } else if s.contains("orgchart") || s.contains("organizationchart") || s.contains("organizationalchart") { + "orgChart" + } else if s.contains("hierarchy") { "hierarchy" } else if s.contains("radial") { "radial" @@ -211,9 +227,11 @@ fn category(kind: &str) -> &'static str { match kind { "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" | "gear" | "funnel" => "process", "venn" | "target" => "relationship", - "cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" => "cycle", - "hierarchy" | "horizontalHierarchy" => "hierarchy", - "pyramid" | "invertedPyramid" => "pyramid", + "cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "cycleMatrix" | "pie" => { + "cycle" + } + "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" => "hierarchy", + "pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid", "matrix" | "titledMatrix" | "gridMatrix" => "matrix", _ => "list", } @@ -267,7 +285,11 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, Strin // Node k: model id 1 + 3k; its link's parent and sibling transitions 2 + 3k and 3 + 3k. let node = |k: usize| guid(seed, 1 + 3 * k); for (k, n) in d.nodes.iter().enumerate() { - w.open("dgm:pt", A::new().a("modelId", node(k))); + let mut a = A::new().a("modelId", node(k)); + if n.assistant { + a = a.a("type", "asst"); + } + w.open("dgm:pt", a); w.empty("dgm:prSet", A::new().a("phldrT", "[Text]")); w.empty0("dgm:spPr"); text(&mut w, &n.text); @@ -367,14 +389,18 @@ pub fn node_pictures(data: &El) -> Vec<(usize, String)> { fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { let Some(pts) = data.child("ptLst") else { return vec![] }; let mut texts: HashMap<&str, String> = HashMap::new(); + let mut assistants: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut doc: Option<&str> = None; for pt in pts.children_named("pt").take(MAX_NODES * 4) { let Some(id) = pt.attr("modelId") else { continue }; match pt.attr("type").unwrap_or("node") { "doc" => doc = doc.or(Some(id)), - "node" => { + ty @ ("node" | "asst") => { let t = pt.child("t").map(paragraphs).unwrap_or_default(); texts.insert(id, t); + if ty == "asst" { + assistants.insert(id); + } } _ => {} } @@ -403,7 +429,10 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { if out.len() >= MAX_NODES || !seen.insert(id) { continue; } - out.push((id.to_string(), DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None })); + out.push(( + id.to_string(), + DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None, assistant: assistants.contains(id) }, + )); if let Some(v) = kids.get(id) { stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); } @@ -432,11 +461,19 @@ fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static } "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), - "cycle" | "radial" | "venn" | "target" | "textCycle" | "continuousCycle" | "gear" => { - ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse") - } + "cycle" + | "radial" + | "venn" + | "target" + | "textCycle" + | "continuousCycle" + | "gear" + | "segmentedCycle" + | "multidirectionalCycle" + | "cycleMatrix" + | "pie" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), "blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"), - "pyramid" | "invertedPyramid" => { + "pyramid" | "invertedPyramid" | "segmentedPyramid" => { ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") } "matrix" | "titledMatrix" | "gridMatrix" | "blockList" => ( @@ -502,7 +539,7 @@ pub fn layout_xml(kind: &str) -> Vec { w.empty0("dgm:dir"); w.empty("dgm:resizeHandles", A::new().a("val", "exact")); w.close("dgm:varLst"); - if matches!(kind, "hierarchy" | "horizontalHierarchy") { + if matches!(kind, "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart") { // Each node over the row of its children, recursively. w.empty("dgm:alg", A::new().a("type", "hierChild")); w.open("dgm:shape", A::new().a("r:blip", "")); @@ -671,7 +708,7 @@ mod tests { use super::*; fn nodes(v: &[(&str, u8)]) -> Vec { - v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None }).collect() + v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None, assistant: false }).collect() } #[test] @@ -698,6 +735,17 @@ mod tests { } } + #[test] + fn assistants_survive_the_data_model() { + let mut v = nodes(&[("CEO", 0), ("Aide", 1), ("CTO", 1)]); + v[1].assistant = true; + let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() }; + let xml = data_xml(&d, 5, "rId1", &[]); + assert!(String::from_utf8(xml.clone()).unwrap().contains("type=\"asst\"")); + let doc = crate::xml::parse(&xml).unwrap(); + assert_eq!(outline(&doc.root), d.nodes); + } + #[test] fn node_pictures_come_from_presentation_points() { let d = Diagram { kind: "pictureList".into(), nodes: nodes(&[("A", 0), ("a", 1), ("B", 0)]), ..Default::default() }; @@ -734,6 +782,13 @@ mod tests { "gridMatrix", "gear", "funnel", + "segmentedCycle", + "multidirectionalCycle", + "orgChart", + "halfCircleOrgChart", + "cycleMatrix", + "segmentedPyramid", + "pie", ] { assert_eq!(kind_for(layout_id(k), ""), k); } @@ -744,7 +799,8 @@ mod tests { let xml = br#"A"#; let doc = crate::xml::parse(xml).unwrap(); assert_eq!(outline(&doc.root), nodes(&[("A", 0), ("", 1)])); - assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "hierarchy"); + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "orgChart"); + assert_eq!(kind_for("urn:x/layout/NameandTitleOrganizationalChart", ""), "orgChart"); assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "chevron"); assert_eq!(kind_for("urn:x/layout/hProcess9", "process"), "process"); assert_eq!(kind_for("urn:x/layout/default", "list"), "blockList"); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 66706d18..f996a8dd 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2338,9 +2338,13 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { rows(ui, |ui| { let (r, _) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); egui::Popup::menu(&r).show(|ui| { - for (label, pos) in - [("Add Shape After", "after"), ("Add Shape Before", "before"), ("Add Shape Above", "above"), ("Add Shape Below", "below")] - { + for (label, pos) in [ + ("Add Shape After", "after"), + ("Add Shape Before", "before"), + ("Add Shape Above", "above"), + ("Add Shape Below", "below"), + ("Add Assistant", "assistant"), + ] { if ui.button(label).clicked() { let index = if n == 0 { Value::Null } else { json!(at) }; run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); From 7764c27cc4012000adb2db63ea85f4b66c20705a Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:26:58 +0300 Subject: [PATCH 10/17] SmartArt: Name and Title org chart, closer Half Circle, ten more layouts, convert to and from text and shapes Name and Title Organization Chart with a title box per node (DiagramNode.title, smartArt.setNodes title; written as the title box's presentation point text and read back). Half Circle Org Chart draws two half rings per node. New layouts: Vertical Box List, Trapezoid List, Table List, Accent Process, Continuous Block Process, Basic Bending Process, Linear Venn, Stacked Venn, Nested Target. smartArt.fromText (Convert to SmartArt), smartArt.toText and smartArt.toShapes, with a Convert menu in SmartArt Design. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 399 ++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 32 +++ crates/model/src/lib.rs | 3 + crates/pptx/src/dgm.rs | 85 ++++++- crates/ui-egui/src/ribbon.rs | 9 + 5 files changed, 505 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index cd3534be..70e0fa9a 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -23,7 +23,7 @@ pub const MAX_LEVEL: u8 = 8; const MAX_SHAPES: usize = 40; /// DeckCraft's layouts, with the names PowerPoint's gallery gives them. -pub const LAYOUTS: [(&str, &str); 35] = [ +pub const LAYOUTS: [(&str, &str); 45] = [ ("list", "Vertical Bullet List"), ("blockList", "Basic Block List"), ("hBulletList", "Horizontal Bullet List"), @@ -59,6 +59,16 @@ pub const LAYOUTS: [(&str, &str); 35] = [ ("cycleMatrix", "Cycle Matrix"), ("segmentedPyramid", "Segmented Pyramid"), ("pie", "Basic Pie"), + ("nameTitleOrgChart", "Name and Title Organization Chart"), + ("verticalBoxList", "Vertical Box List"), + ("trapezoidList", "Trapezoid List"), + ("tableList", "Table List"), + ("accentProcess", "Accent Process"), + ("continuousBlockProcess", "Continuous Block Process"), + ("bendingProcess", "Basic Bending Process"), + ("linearVenn", "Linear Venn"), + ("stackedVenn", "Stacked Venn"), + ("nestedTarget", "Nested Target"), ]; /// SmartArt Styles. pub const STYLES: [(&str, &str); 9] = [ @@ -113,7 +123,7 @@ pub fn specs() -> Vec { "Edit SmartArt Text", ["SmartArt Design", "Create Graphic"], None, - "{id?, nodes?: [{text, level}], lines?: [string] (leading tabs set the level), index?, text?} (index + text edits one node)", + "{id?, nodes?: [{text, level}], lines?: [string] (leading tabs set the level), index?, text?, title?} (index + text edits one node; title: its Name and Title box)", has_selection, set_nodes ), @@ -142,10 +152,37 @@ pub fn specs() -> Vec { "Change Layout", ["SmartArt Design", "Layouts"], None, - "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie|nameTitleOrgChart|verticalBoxList|trapezoidList|tableList|accentProcess|continuousBlockProcess|bendingProcess|linearVenn|stackedVenn|nestedTarget}", has_selection, change_layout ), + cmd!( + "smartArt.fromText", + "Convert to SmartArt", + ["Home", "Paragraph"], + None, + "{id?, kind?} → {id} (a text box or placeholder becomes a SmartArt graphic in its place: one node per paragraph, indent levels kept)", + has_selection, + from_text + ), + cmd!( + "smartArt.toText", + "Convert to Text", + ["SmartArt Design", "Reset"], + None, + "{id?} → {id} (the graphic becomes a text box of its outline, bulleted by level)", + has_selection, + to_text + ), + cmd!( + "smartArt.toShapes", + "Convert to Shapes", + ["SmartArt Design", "Reset"], + None, + "{id?} (the graphic becomes a plain group of its shapes; the outline is dropped)", + has_selection, + to_shapes + ), cmd!( "smartArt.picture", "Insert Picture in SmartArt", @@ -183,7 +220,13 @@ pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec usize { /// new node's index. pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { let Some(i) = index.filter(|i| *i < nodes.len()) else { - nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false }); + nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false, title: String::new() }); return nodes.len() - 1; }; let lv = nodes[i].level; @@ -232,7 +275,7 @@ pub fn add(nodes: &mut Vec, index: Option, position: &str, t "assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)), _ => (subtree_end(nodes, i), lv), }; - nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant" }); + nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant", title: String::new() }); at } @@ -593,7 +636,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { match d.kind.as_str() { "hierarchy" => return hierarchy(d, area, false), "horizontalHierarchy" => return hierarchy(d, area, true), - "orgChart" | "halfCircleOrgChart" => return org_chart(d, area, d.kind == "halfCircleOrgChart"), + "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area), "list" | "hBulletList" => { // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. let vertical = d.kind == "list"; @@ -990,6 +1033,176 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } } } + "verticalBoxList" => { + // Vertical Box List: a pale box per node with its sub-points, its title on a tab + // overlapping the box's top left. + let h = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let tab_h = (h * 0.3).min(40.0); + let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &nodes(&g[1..]), false); + paint(d, &mut bx, i, true); + if let Some(b) = &mut bx.text { + b.body.inset_t = Some(tab_h * 0.6); + } + out.push((bx, g[1..].to_vec())); + titled(&mut out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]); + } + } + "trapezoidList" | "tableList" => { + // Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the + // first node as a header bar over a column per other node. + let table = d.kind == "tableList"; + let (head, cols): (Option<&Vec>, &[Vec]) = + if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, &gs) }; + let top = if head.is_some() { area.h * 0.28 } else { 0.0 }; + if let Some(h) = head { + titled(&mut out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h); + } + let m = cols.len().max(1) as f64; + let w = area.w / m; + for (k, g) in cols.iter().enumerate() { + let i = k + usize::from(table); + let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top); + let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + if !table { + b.body.inset_l = Some(x.w * 0.2); + b.body.inset_r = Some(x.w * 0.2); + b.body.inset_t = Some(x.h * 0.08); + } + } + paint(d, &mut sh, i, table); + out.push((sh, g.clone())); + } + } + "accentProcess" => { + // Accent Process: a title box per step with its sub-points in a pale box below and to the + // right, arrows between the titles. + let gap = area.w * 0.05; + let w = (area.w - gap * (nf - 1.0)) / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + let th = area.h * 0.22; + if g.len() > 1 { + let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(b) = &mut sh.text { + b.body.inset_t = Some(th * 0.45); + } + out.push((sh, g[1..].to_vec())); + } + titled(&mut out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]); + if i + 1 < n { + let a = gap * 0.8; + arrow(&mut out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i); + } + } + } + "continuousBlockProcess" => { + // Continuous Block Process: one big pale arrow behind a row of boxes. + let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0); + paint(d, &mut big, 0, true); + out.push((big, vec![])); + let w = area.w * 0.85 / nf; + let h = area.h * 0.32; + for (i, g) in gs.iter().enumerate() { + let x = area.x + area.w * 0.02 + i as f64 * w; + titled(&mut out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g); + } + } + "bendingProcess" => { + // Basic Bending Process: rows of boxes read left to right, wrapping, arrows between. + let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (cw, rh) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let x = area.x + c as f64 * cw; + let y = area.y + r as f64 * rh + (rh - bh) / 2.0; + titled(&mut out, "rect", Xfrm::new(x, y, bw, bh), i, g); + if i + 1 < n { + let a = (cw - bw) * 0.7; + if c + 1 < cols { + arrow(&mut out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i); + } else { + // The bend: down from the row's last box towards the next row's first. + let a = (rh - bh).min(a * 1.5).max(6.0); + arrow(&mut out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i); + } + } + } + } + "linearVenn" | "stackedVenn" => { + // Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom, + // the text near each one's top. + let linear = d.kind == "linearVenn"; + if linear { + let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h); + let step = dc * 0.75; + let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0; + for (i, g) in gs.iter().enumerate() { + let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(55_000)); + } + out.push((sh, g.clone())); + } + } else { + let s = area.h.min(area.w); + let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0); + for (i, _) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + // Each circle's text in the band between its top and the next circle's. + let band = s / nf; + for (i, g) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let top = bottom - dc; + let h = if i + 1 < n { band } else { dc }; + let w = dc * if i + 1 < n { 0.6 } else { 0.7 }; + let mut t = + text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } + } + "nestedTarget" => { + // Nested Target: rounded boxes nested inside each other, the text on each one's top band. + let band_x = area.w / 2.0 / nf * 0.6; + let band_y = area.h / nf * 0.9 / 2.0; + for (i, _) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy }; + let mut t = text_shape( + "rect", + Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h), + &nodes(g), + true, + ); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } "gridMatrix" => { // Grid Matrix: four quadrants on crossed axes. let s = area.h.min(area.w); @@ -1154,7 +1367,9 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { /// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on /// a row of their own between the parent and its other children. `half_circle` draws each node as /// its text in an open half ring (Half Circle Organization Chart). -fn org_chart(d: &Diagram, area: Xfrm, half_circle: bool) -> Vec { +fn org_chart(d: &Diagram, area: Xfrm) -> Vec { + let half_circle = d.kind == "halfCircleOrgChart"; + let name_title = d.kind == "nameTitleOrgChart"; let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; if nodes.is_empty() { return vec![]; @@ -1260,21 +1475,51 @@ fn org_chart(d: &Diagram, area: Xfrm, half_circle: bool) -> Vec { for (i, n) in nodes.iter().enumerate() { let depth = slot[i] / 2; if half_circle { - let s = boxes[i].h.min(boxes[i].w); + // Two half rings round the text: the upper left one in the accent, the lower right one + // in its tint. let x = boxes[i]; - let mut arc = deco("blockArc", Xfrm::new(x.x + (x.w - s) / 2.0, x.y, s, s), 0.0); - arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![180.0 * 60_000.0, 0.0, 8_000.0] }; - paint(d, &mut arc, depth, false); - out.push((arc, vec![])); + for (k, (a0, a1)) in [(140.0, 310.0), (320.0, 130.0)].into_iter().enumerate() { + let mut arc = deco("blockArc", x, 0.0); + arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![a0 * 60_000.0, a1 * 60_000.0, 4_000.0] }; + paint(d, &mut arc, depth, false); + if k == 1 { + let c = ColorRef::scheme(accent(d, depth)).with(ColorTransform::LumMod(60_000)).with(ColorTransform::LumOff(40_000)); + arc.fill = Some(Fill::solid(c)); + } + arc.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + arc.effects = None; + out.push((arc, vec![])); + } let mut t = text_shape("rect", x, &[n], true); paint(d, &mut t, depth, true); t.fill = Some(Fill::None); t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); out.push((t, vec![i])); } else { - let mut sh = text_shape("rect", boxes[i], &[n], true); + let mut x = boxes[i]; + if name_title { + // The name in the upper part; the title box overlaps the lower edge. + x.h *= 0.85; + } + let mut sh = text_shape("rect", x, &[n], true); paint(d, &mut sh, depth, false); out.push((sh, vec![i])); + if name_title { + let tb = Xfrm::new(x.x + x.w * 0.12, x.y + x.h * 0.72, x.w * 0.8, x.h * 0.42); + let title = DiagramNode { text: n.title.clone(), ..Default::default() }; + let mut t = text_shape("rect", tb, &[&title], true); + paint(d, &mut t, depth, true); + t.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + p.end_props.size = Some(11.0); + } + } + out.push((t, vec![])); + } } } out @@ -1444,7 +1689,7 @@ fn index(p: &Value, cmd: &str) -> Result { pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { let kind = if is_kind(kind) { kind } else { "process" }; if nodes.is_empty() { - nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false }).collect(); + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false, title: String::new() }).collect(); } normalize(&mut nodes); let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; @@ -1488,6 +1733,7 @@ fn set_nodes(s: &mut Session, p: &Value) -> Result { }; let one = p.get("index").map(|_| index(p, CMD)).transpose()?; let text = str_param(p, "text").map(String::from); + let title = str_param(p, "title").map(String::from); edit_diagram(s, p, CMD, |d| { if let Some(n) = given { d.nodes = n; @@ -1495,6 +1741,9 @@ fn set_nodes(s: &mut Session, p: &Value) -> Result { if let (Some(i), Some(t)) = (one, text) { d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t; } + if let (Some(i), Some(t)) = (one, title) { + d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.title = t; + } Ok(Value::Null) }) } @@ -1527,6 +1776,120 @@ fn demote(s: &mut Session, p: &Value) -> Result { edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) } +/// Convert to SmartArt: the selected text shape's paragraphs become the nodes of a new graphic in +/// the shape's place. +fn from_text(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.fromText"; + let kind = str_param(p, "kind").filter(|k| is_kind(k)).unwrap_or("list").to_string(); + let id = *targets(s, p)?.first().ok_or_else(|| bad(CMD, "select a text box"))?; + let (area, nodes) = { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + let sh = shapes.iter().find(|x| x.id == id).ok_or_else(|| bad(CMD, "select a text box on the slide"))?; + let body = + sh.text.as_ref().filter(|b| b.paragraphs.iter().any(|p| !p.text().trim().is_empty())).ok_or_else(|| bad(CMD, "the shape has no text"))?; + let mut nodes: Vec = body + .paragraphs + .iter() + .filter(|p| !p.text().trim().is_empty()) + .map(|p| DiagramNode { text: p.text().trim().to_string(), level: p.level.min(MAX_LEVEL), ..Default::default() }) + .collect(); + normalize(&mut nodes); + // A small text box grows to a size a diagram can use, keeping its top left on the slide. + let mut a = xfrm_of(&st.doc, &st.selection, sh); + let size = st.doc.slide_size; + a.w = a.w.max(size.width * 0.5).min(size.width); + a.h = a.h.max(size.height * 0.35).min(size.height); + a.x = a.x.min(size.width - a.w).max(0.0); + a.y = a.y.min(size.height - a.h).max(0.0); + a.rot = 0.0; + (a, nodes) + }; + let d = Diagram { kind: kind.clone(), nodes, ..Default::default() }; + let gid = s.edit(|doc, sel| { + let children = regenerate(doc, &d, area); + let gid = ShapeId(doc.alloc_id()); + let g = Shape { + id: gid, + name: format!("SmartArt ({kind})"), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + diagram: Some(d), + ..Default::default() + }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let at = shapes.iter().position(|x| x.id == id).ok_or_else(|| bad(CMD, "no such shape"))?; + // In the text shape's place in the z-order. + shapes[at] = g; + sel.shapes = vec![gid]; + sel.text = None; + Ok(gid) + })?; + Ok(json!({"id": gid.0})) +} + +/// Convert to Text: a text box with the outline, one bulleted paragraph per node. +fn to_text(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.toText"; + let gid = target_group(s, p, CMD)?; + let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + let new = s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?; + let area = shapes[at].xfrm.unwrap_or_default(); + let mut body = TextBody::default(); + for n in &d.nodes { + let mut para = Paragraph::new(&n.text); + para.level = n.level.min(MAX_LEVEL); + para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); + para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0)); + para.props.indent = Some(-14.0); + body.paragraphs.push(para); + } + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::new("")); + } + body.body.wrap = Some(true); + body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); + let id = ShapeId(doc.alloc_id()); + let tb = + Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + shapes[at] = tb; + sel.shapes = vec![id]; + sel.text = None; + Ok(id) + })?; + Ok(json!({"id": new.0})) +} + +/// Convert to Shapes: keep the drawing as an ordinary group. +fn to_shapes(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.toShapes"; + let gid = target_group(s, p, CMD)?; + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let g = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(CMD, "no such shape"))?; + g.diagram = None; + g.name = "Group".into(); + sel.shapes = vec![gid]; + Ok(()) + })?; + Ok(Value::Null) +} + +/// The graphic `gid` with its outline (typed text synced). +fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> { + let st = s.doc().ok()?; + let shapes = current_shapes(&st.doc, &st.selection)?; + let g = diagram_group(shapes, gid)?; + let mut d = g.diagram.clone()?; + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Some((g.id, d)) +} + fn set_picture(s: &mut Session, p: &Value) -> Result { const CMD: &str = "smartArt.picture"; let i = index(p, CMD)?; @@ -1593,7 +1956,11 @@ mod tests { n.iter().map(|x| x.level).collect() } fn mk(levels: &[u8]) -> Vec { - levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None, assistant: false }).collect() + levels + .iter() + .enumerate() + .map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None, assistant: false, title: String::new() }) + .collect() } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e75b25a8..ac033837 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -975,3 +975,35 @@ fn smartart_pictures_3d_styles_and_colour_variants() { assert_eq!((d.style.as_str(), d.colors.as_str()), (style, colors)); } } + +#[test] +fn smartart_converts_to_and_from_text_and_shapes() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.textBox", &json!({"text": "Goals\nRevenue\nGrowth"})).unwrap(); + let before = s.doc().unwrap().doc.slides[1].shapes.len(); + let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.slides[1].shapes.len(), before, "the text box is replaced, not added to"); + let d = st.doc.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); + assert_eq!(d.nodes.iter().map(|n| n.text.as_str()).collect::>(), ["Goals", "Revenue", "Growth"]); + + // Name and Title: titles show in their own box and survive a .pptx round trip. + s.execute("smartArt.layout", &json!({"id": gid, "kind": "nameTitleOrgChart"})).unwrap(); + s.execute("smartArt.setNodes", &json!({"id": gid, "index": 0, "title": "Lead"})).unwrap(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap()).unwrap(); + let bd = back.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); + assert_eq!((bd.kind.as_str(), bd.nodes[0].title.as_str()), ("nameTitleOrgChart", "Lead")); + + let tid = s.execute("smartArt.toText", &json!({"id": gid})).unwrap()["id"].as_u64().unwrap(); + let st = s.doc().unwrap(); + let tb = st.doc.slides[1].shapes.iter().find(|x| x.id.0 as u64 == tid).unwrap(); + assert!(tb.diagram.is_none()); + assert_eq!(tb.text.as_ref().unwrap().paragraphs.len(), 3); + + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("smartArt.toShapes", &json!({"id": gid})).unwrap(); + let st = s.doc().unwrap(); + let g = st.doc.slides[1].shapes.iter().find(|x| x.id.0 as u64 == gid).unwrap(); + assert!(g.diagram.is_none() && !g.children().is_empty(), "a plain group of the same shapes"); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 2d836397..bad27088 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -368,6 +368,9 @@ pub struct DiagramNode { /// An assistant (organisation charts): drawn beside its parent, off the row of its siblings. #[serde(skip_serializing_if = "std::ops::Not::not")] pub assistant: bool, + /// The job title under the name (Name and Title Organization Chart). + #[serde(skip_serializing_if = "String::is_empty")] + pub title: String, } impl Default for Shape { diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index 90d1bedc..5f85db00 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -126,6 +126,16 @@ pub fn layout_id(kind: &str) -> &'static str { "multidirectionalCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle7", "orgChart" => "urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", "halfCircleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/HalfCircleOrganizationChart", + "nameTitleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/NameandTitleOrganizationalChart", + "verticalBoxList" => "urn:microsoft.com/office/officeart/2005/8/layout/list1", + "trapezoidList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList6", + "tableList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList3", + "accentProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process3", + "continuousBlockProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess9", + "bendingProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process5", + "linearVenn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn3", + "stackedVenn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn2", + "nestedTarget" => "urn:microsoft.com/office/officeart/2005/8/layout/target2", "cycleMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle4", "segmentedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid4", "pie" => "urn:microsoft.com/office/officeart/2005/8/layout/chart3", @@ -174,6 +184,16 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "multidirectionalCycle", "orgChart", "halfCircleOrgChart", + "nameTitleOrgChart", + "verticalBoxList", + "trapezoidList", + "tableList", + "accentProcess", + "continuousBlockProcess", + "bendingProcess", + "linearVenn", + "stackedVenn", + "nestedTarget", "cycleMatrix", "segmentedPyramid", "pie", @@ -194,6 +214,8 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "target" } else if s.contains("timeline") { "timeline" + } else if s.contains("nameandtitle") { + "nameTitleOrgChart" } else if s.contains("orgchart") || s.contains("organizationchart") || s.contains("organizationalchart") { "orgChart" } else if s.contains("hierarchy") { @@ -225,12 +247,22 @@ fn layout_id_of(kind: &str) -> &'static str { fn category(kind: &str) -> &'static str { match kind { - "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" | "gear" | "funnel" => "process", - "venn" | "target" => "relationship", + "process" + | "chevron" + | "verticalProcess" + | "stepUp" + | "pictureProcess" + | "timeline" + | "gear" + | "funnel" + | "accentProcess" + | "continuousBlockProcess" + | "bendingProcess" => "process", + "venn" | "target" | "linearVenn" | "stackedVenn" | "nestedTarget" => "relationship", "cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "cycleMatrix" | "pie" => { "cycle" } - "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" => "hierarchy", + "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => "hierarchy", "pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid", "matrix" | "titledMatrix" | "gridMatrix" => "matrix", _ => "list", @@ -305,6 +337,17 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, Strin } // A node's picture sits on the presentation point of its picture placeholder, as PowerPoint // stores it (`presAssocID` names the node, the blip is a relationship of the data part). + // Name and Title: a node's title is the text of its title box's presentation point. + for (k, n) in d.nodes.iter().enumerate() { + if n.title.is_empty() { + continue; + } + w.open("dgm:pt", A::new().a("modelId", guid(seed, 0x30_0000 + k)).a("type", "pres")); + w.empty("dgm:prSet", A::new().a("presAssocID", node(k)).a("presName", "titleText1").a("presStyleLbl", "fgAcc1").a("custT", "1")); + w.empty0("dgm:spPr"); + text(&mut w, &n.title); + w.close("dgm:pt"); + } for (k, rid) in pics { if *k >= d.nodes.len() { continue; @@ -390,6 +433,7 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { let Some(pts) = data.child("ptLst") else { return vec![] }; let mut texts: HashMap<&str, String> = HashMap::new(); let mut assistants: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut titles: HashMap<&str, String> = HashMap::new(); let mut doc: Option<&str> = None; for pt in pts.children_named("pt").take(MAX_NODES * 4) { let Some(id) = pt.attr("modelId") else { continue }; @@ -402,6 +446,16 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { assistants.insert(id); } } + "pres" => { + // Name and Title: the title box's own text, on its presentation point. + let pr = pt.child("prSet"); + if pr.and_then(|p| p.attr("presName")).is_some_and(|n| n.starts_with("titleText")) + && let Some(node) = pr.and_then(|p| p.attr("presAssocID")) + && let Some(t) = pt.child("t").map(paragraphs).filter(|t| !t.is_empty()) + { + titles.insert(node, t); + } + } _ => {} } } @@ -431,7 +485,13 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { } out.push(( id.to_string(), - DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None, assistant: assistants.contains(id) }, + DiagramNode { + text: texts.get(id).cloned().unwrap_or_default(), + level: level.min(8), + picture: None, + assistant: assistants.contains(id), + title: titles.get(id).cloned().unwrap_or_default(), + }, )); if let Some(v) = kids.get(id) { stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); @@ -539,7 +599,7 @@ pub fn layout_xml(kind: &str) -> Vec { w.empty0("dgm:dir"); w.empty("dgm:resizeHandles", A::new().a("val", "exact")); w.close("dgm:varLst"); - if matches!(kind, "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart") { + if matches!(kind, "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart") { // Each node over the row of its children, recursively. w.empty("dgm:alg", A::new().a("type", "hierChild")); w.open("dgm:shape", A::new().a("r:blip", "")); @@ -708,7 +768,7 @@ mod tests { use super::*; fn nodes(v: &[(&str, u8)]) -> Vec { - v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None, assistant: false }).collect() + v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None, assistant: false, title: String::new() }).collect() } #[test] @@ -739,6 +799,7 @@ mod tests { fn assistants_survive_the_data_model() { let mut v = nodes(&[("CEO", 0), ("Aide", 1), ("CTO", 1)]); v[1].assistant = true; + v[0].title = "Chief Executive".into(); let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() }; let xml = data_xml(&d, 5, "rId1", &[]); assert!(String::from_utf8(xml.clone()).unwrap().contains("type=\"asst\"")); @@ -789,6 +850,16 @@ mod tests { "cycleMatrix", "segmentedPyramid", "pie", + "nameTitleOrgChart", + "verticalBoxList", + "trapezoidList", + "tableList", + "accentProcess", + "continuousBlockProcess", + "bendingProcess", + "linearVenn", + "stackedVenn", + "nestedTarget", ] { assert_eq!(kind_for(layout_id(k), ""), k); } @@ -800,7 +871,7 @@ mod tests { let doc = crate::xml::parse(xml).unwrap(); assert_eq!(outline(&doc.root), nodes(&[("A", 0), ("", 1)])); assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "orgChart"); - assert_eq!(kind_for("urn:x/layout/NameandTitleOrganizationalChart", ""), "orgChart"); + assert_eq!(kind_for("urn:x/layout/NameandTitleOrganizationalChart", ""), "nameTitleOrgChart"); assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "chevron"); assert_eq!(kind_for("urn:x/layout/hProcess9", "process"), "process"); assert_eq!(kind_for("urn:x/layout/default", "list"), "blockList"); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f996a8dd..2d47d62b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2375,6 +2375,15 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { } } }); + let (r, _) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + if ui.button("Convert to Text").clicked() { + run(app, "smartArt.toText", json!({"id": gid})); + } + if ui.button("Convert to Shapes").clicked() { + run(app, "smartArt.toShapes", json!({"id": gid})); + } + }); }); group(ui, |ui| { let (r, _) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); From 33252f28dbd8554b29962251a56fa903c0157e10 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:34:55 +0300 Subject: [PATCH 11/17] SmartArt: Convert to SmartArt gallery on Home, Convert to Text keeps the text size Home > Paragraph gets a Convert to SmartArt button whose menu lists every layout by category (List, Process, Cycle, Hierarchy, Relationship, Matrix, Pyramid, Picture); a click converts the selected text. Convert to Text sets the text box's titles and bullets to the sizes the graphic showed them at. Icon toggles carry their tooltip as their accessible name. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 62 ++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 2 + crates/ui-egui/src/ribbon.rs | 37 +++++++++++++++++ crates/ui-egui/src/smartart_tests.rs | 24 +++++++++++ crates/ui-egui/src/widgets.rs | 2 + 5 files changed, 127 insertions(+) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 70e0fa9a..66b8dda4 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -103,6 +103,29 @@ pub const COLORS: [(&str, &str); 17] = [ ("accent6Grad", "Gradient Range - Accent 6"), ]; +/// The gallery category a layout is shown under in Convert to SmartArt and Change Layout. +pub fn category(kind: &str) -> &'static str { + match kind { + "pictureList" | "pictureCaption" | "pictureProcess" | "pictureHList" => "picture", + "process" + | "chevron" + | "verticalProcess" + | "stepUp" + | "timeline" + | "gear" + | "funnel" + | "accentProcess" + | "continuousBlockProcess" + | "bendingProcess" => "process", + "cycle" | "blockCycle" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "pie" => "cycle", + "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => "hierarchy", + "radial" | "venn" | "target" | "linearVenn" | "stackedVenn" | "nestedTarget" => "relationship", + "matrix" | "titledMatrix" | "gridMatrix" | "cycleMatrix" => "matrix", + "pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid", + _ => "list", + } +} + pub fn is_kind(k: &str) -> bool { LAYOUTS.iter().any(|(id, _)| *id == k) } @@ -1833,6 +1856,13 @@ fn to_text(s: &mut Session, p: &Value) -> Result { const CMD: &str = "smartArt.toText"; let gid = target_group(s, p, CMD)?; let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + // The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included). + let (title_pt, bullet_pt) = { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + text_sizes(g.children()) + }; let new = s.edit(|doc, sel| { let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?; @@ -1841,6 +1871,11 @@ fn to_text(s: &mut Session, p: &Value) -> Result { for n in &d.nodes { let mut para = Paragraph::new(&n.text); para.level = n.level.min(MAX_LEVEL); + let size = if n.level == 0 { title_pt } else { bullet_pt }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0)); para.props.indent = Some(-14.0); @@ -1878,6 +1913,33 @@ fn to_shapes(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// The most common title and bullet font sizes in a diagram's shapes (20 and 14 pt when none). +fn text_sizes(children: &[Shape]) -> (f64, f64) { + let mut titles: Vec = vec![]; + let mut bullets: Vec = vec![]; + for c in children { + let Some(b) = &c.text else { continue }; + for p in &b.paragraphs { + let Some(sz) = p.runs.iter().find_map(|r| r.props.size) else { continue }; + if p.props.bullet.is_some() { bullets.push(sz) } else { titles.push(sz) } + } + } + let mode = |v: &mut Vec, d: f64| { + v.sort_by(|a, b| a.total_cmp(b)); + let mut best = (d, 0usize); + let mut k = 0; + while k < v.len() { + let j = v[k..].iter().take_while(|x| (**x - v[k]).abs() < 1e-9).count(); + if j > best.1 { + best = (v[k], j); + } + k += j; + } + best.0 + }; + (mode(&mut titles, 20.0), mode(&mut bullets, 14.0)) +} + /// The graphic `gid` with its outline (typed text synced). fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> { let st = s.doc().ok()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ac033837..327294c4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1000,6 +1000,8 @@ fn smartart_converts_to_and_from_text_and_shapes() { let tb = st.doc.slides[1].shapes.iter().find(|x| x.id.0 as u64 == tid).unwrap(); assert!(tb.diagram.is_none()); assert_eq!(tb.text.as_ref().unwrap().paragraphs.len(), 3); + // The text keeps the size it had in the graphic. + assert!(tb.text.as_ref().unwrap().paragraphs.iter().all(|p| p.runs.iter().all(|r| r.props.size.is_some_and(|z| z > 10.0)))); s.execute("edit.undo", &json!({})).unwrap(); s.execute("smartArt.toShapes", &json!({"id": gid})).unwrap(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2d47d62b..0b8c4e6f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -539,6 +539,41 @@ pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { } } +/// Convert to SmartArt: the layouts by category, three to a row; a click converts the selected +/// text. +fn smartart_gallery(app: &mut SlideApp, ui: &mut Ui) { + use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; + ui.set_min_width(420.0); + for (cat, title) in [ + ("list", "List"), + ("process", "Process"), + ("cycle", "Cycle"), + ("hierarchy", "Hierarchy"), + ("relationship", "Relationship"), + ("matrix", "Matrix"), + ("pyramid", "Pyramid"), + ("picture", "Picture"), + ] { + let kinds: Vec<(&str, &str)> = LAYOUTS.iter().copied().filter(|(k, _)| category(k) == cat).collect(); + if kinds.is_empty() { + continue; + } + ui.label(egui::RichText::new(title).strong()); + egui::Grid::new(("smartart_gallery", cat)).num_columns(3).spacing([6.0, 2.0]).show(ui, |ui| { + for (i, (k, label)) in kinds.iter().enumerate() { + if ui.add_sized([136.0, 22.0], egui::Button::new(*label)).clicked() { + run(app, "smartArt.fromText", json!({"kind": k})); + ui.close(); + } + if i % 3 == 2 { + ui.end_row(); + } + } + }); + ui.separator(); + } +} + fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { let en = enabled(app, "format.bullets"); group(ui, |ui| { @@ -637,6 +672,8 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } }); + let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); + egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui)); }); }); }); diff --git a/crates/ui-egui/src/smartart_tests.rs b/crates/ui-egui/src/smartart_tests.rs index 7692f704..9d88e465 100644 --- a/crates/ui-egui/src/smartart_tests.rs +++ b/crates/ui-egui/src/smartart_tests.rs @@ -51,3 +51,27 @@ fn text_pane_types_adds_and_demotes() { h.run_steps(4); assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), ("Test".into(), 1)]); } + +#[test] +fn home_convert_to_smartart_gallery_converts_the_text() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.textBox", &json!({"text": "Goals\nRevenue\nGrowth"})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Convert to SmartArt").click(); + h.run_steps(3); + h.get_by_label("Basic Block List").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let (_, d) = deckcraft_engine::cmd::smartart::selected(st).expect("the text became a SmartArt graphic"); + assert_eq!(d.kind, "blockList"); + assert_eq!(d.nodes.len(), 3); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..1f26fea1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -155,6 +155,8 @@ pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) painter.rect_filled(rect, CornerRadius::same(4), c); } icons::paint(&painter, rect.shrink(3.0), icon, t.text, !enabled); + // Named for screen readers (and tests) by its tooltip. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); resp.on_hover_text(tip) } From a4ed08b687e65795b8e31b097854e7706fc3c764 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:47:28 +0300 Subject: [PATCH 12/17] SmartArt: layout thumbnails in galleries and live preview on hover --- crates/engine/src/cmd/smartart.rs | 28 +++++++ crates/ui-egui/src/ribbon.rs | 109 ++++++++++++++++++--------- crates/ui-egui/src/smartart_tests.rs | 36 ++++++++- crates/ui-egui/src/textures.rs | 48 ++++++++++++ crates/ui-egui/src/widgets.rs | 3 + 5 files changed, 188 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 66b8dda4..03276e9f 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -126,6 +126,34 @@ pub fn category(kind: &str) -> &'static str { } } +/// A layout's gallery picture: the graphic drawn with sample nodes (or `nodes`) in a 320 × 200 pt +/// group at the origin, in the style and colours given, with no text in the samples. +pub fn preview(kind: &str, style: &str, colors: &str, nodes: Option<&[DiagramNode]>) -> Shape { + let sample = |levels: &[u8]| -> Vec { levels.iter().map(|l| DiagramNode { level: *l, ..Default::default() }).collect() }; + let nodes = match nodes { + Some(n) if !n.is_empty() => n.to_vec(), + _ => match category(kind) { + "hierarchy" => sample(&[0, 1, 2, 2, 1]), + "matrix" => sample(&[0, 0, 0, 0, 0]), + "list" if kind != "blockList" && kind != "tableList" => sample(&[0, 1, 0, 1, 0, 1]), + _ => sample(&[0, 0, 0, 0]), + }, + }; + let d = Diagram { kind: kind.into(), nodes, style: style.into(), colors: colors.into(), ..Default::default() }; + let area = Xfrm::new(0.0, 0.0, 320.0, 200.0); + let mut children = layout(&d, area); + for (k, c) in children.iter_mut().enumerate() { + c.id = ShapeId(10_000 + k as u32); + } + Shape { + id: ShapeId(9_999), + name: "SmartArt preview".into(), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + ..Default::default() + } +} + pub fn is_kind(k: &str) -> bool { LAYOUTS.iter().any(|(id, _)| *id == k) } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0b8c4e6f..ea931b96 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -541,37 +541,79 @@ pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { /// Convert to SmartArt: the layouts by category, three to a row; a click converts the selected /// text. -fn smartart_gallery(app: &mut SlideApp, ui: &mut Ui) { +/// A layout gallery grouped by category, each layout as a small cached picture with its name. +/// Clicking runs `cmd` with `{"kind": …}` plus `extra`; hovering shows the layout larger, drawn +/// with `nodes` (the selected graphic's outline) when given: a live preview without touching the +/// document. +fn smartart_gallery( + app: &mut SlideApp, + ui: &mut Ui, + cmd: &str, + extra: Value, + current: &str, + style: &str, + colors: &str, + nodes: Option<&[deckcraft_model::DiagramNode]>, +) { use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; - ui.set_min_width(420.0); - for (cat, title) in [ - ("list", "List"), - ("process", "Process"), - ("cycle", "Cycle"), - ("hierarchy", "Hierarchy"), - ("relationship", "Relationship"), - ("matrix", "Matrix"), - ("pyramid", "Pyramid"), - ("picture", "Picture"), - ] { - let kinds: Vec<(&str, &str)> = LAYOUTS.iter().copied().filter(|(k, _)| category(k) == cat).collect(); - if kinds.is_empty() { - continue; - } - ui.label(egui::RichText::new(title).strong()); - egui::Grid::new(("smartart_gallery", cat)).num_columns(3).spacing([6.0, 2.0]).show(ui, |ui| { - for (i, (k, label)) in kinds.iter().enumerate() { - if ui.add_sized([136.0, 22.0], egui::Button::new(*label)).clicked() { - run(app, "smartArt.fromText", json!({"kind": k})); - ui.close(); - } - if i % 3 == 2 { - ui.end_row(); - } + ui.set_min_width(440.0); + let slide = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + egui::ScrollArea::vertical().max_height(520.0).show(ui, |ui| { + for (cat, title) in [ + ("list", "List"), + ("process", "Process"), + ("cycle", "Cycle"), + ("hierarchy", "Hierarchy"), + ("relationship", "Relationship"), + ("matrix", "Matrix"), + ("pyramid", "Pyramid"), + ("picture", "Picture"), + ] { + let kinds: Vec<(&str, &str)> = LAYOUTS.iter().copied().filter(|(k, _)| category(k) == cat).collect(); + if kinds.is_empty() { + continue; } - }); - ui.separator(); - } + ui.label(egui::RichText::new(title).strong()); + egui::Grid::new(("smartart_gallery", cat)).num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| { + for (i, (k, label)) in kinds.iter().enumerate() { + let ctx = ui.ctx().clone(); + let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, None, 192)); + let r = ui + .vertical_centered(|ui| { + ui.set_width(100.0); + let r = match &thumb { + Some(t) => { + ui.add(egui::Button::image(egui::Image::new(t).fit_to_exact_size(egui::vec2(96.0, 60.0))).selected(*k == current)) + } + None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)), + }; + ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, *label)); + r.on_hover_text(*label) + }) + .inner; + if r.hovered() { + let preview = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, nodes, 480)); + if let Some(t) = preview { + r.show_tooltip_ui(|ui| { + ui.add(egui::Image::new(&t).fit_to_exact_size(egui::vec2(320.0, 200.0))); + }); + } + } + if r.clicked() { + let mut args = extra.clone(); + args["kind"] = json!(k); + run(app, cmd, args); + ui.close(); + } + if i % 4 == 3 { + ui.end_row(); + } + } + }); + ui.separator(); + } + }); } fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { @@ -673,7 +715,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } }); let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); - egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui)); + egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None)); }); }); }); @@ -2405,12 +2447,9 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); + let outline = sel.as_ref().map(|(_, d)| d.nodes.clone()); egui::Popup::menu(&r).show(|ui| { - for (k, label) in deckcraft_engine::cmd::smartart::LAYOUTS { - if ui.selectable_label(kind == k, label).clicked() { - run(app, "smartArt.layout", json!({"id": gid, "kind": k})); - } - } + smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref()); }); let (r, _) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); egui::Popup::menu(&r).show(|ui| { diff --git a/crates/ui-egui/src/smartart_tests.rs b/crates/ui-egui/src/smartart_tests.rs index 9d88e465..0e668439 100644 --- a/crates/ui-egui/src/smartart_tests.rs +++ b/crates/ui-egui/src/smartart_tests.rs @@ -68,10 +68,44 @@ fn home_convert_to_smartart_gallery_converts_the_text() { h.run_steps(3); h.get_by_label("Convert to SmartArt").click(); h.run_steps(3); - h.get_by_label("Basic Block List").click(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Basic Block List").click(); h.run_steps(3); let st = h.state().session.active().unwrap(); let (_, d) = deckcraft_engine::cmd::smartart::selected(st).expect("the text became a SmartArt graphic"); assert_eq!(d.kind, "blockList"); assert_eq!(d.nodes.len(), 3); } + +#[test] +fn layout_gallery_shows_cached_pictures_and_previews_on_hover() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "process", "items": ["Plan", "Build", "Ship"]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "SmartArt Design".into(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Layouts").click(); + h.run_steps(3); + let first = h.state().textures.smartart_cached(); + assert!(first >= deckcraft_engine::cmd::smartart::LAYOUTS.len().min(8), "gallery pictures were drawn: {first}"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Vertical Box List").hover(); + h.run_steps(3); + if let Ok(path) = std::env::var("SMARTART_GALLERY_PNG") { + h.render().expect("render").save(path).unwrap(); + } + let hovered = h.state().textures.smartart_cached(); + assert!(hovered > first, "hovering drew a preview of the graphic's own outline"); + h.run_steps(3); + assert_eq!(h.state().textures.smartart_cached(), hovered, "pictures are cached, not redrawn every frame"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Vertical Box List").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!(deckcraft_engine::cmd::smartart::selected(st).unwrap().1.kind, "verticalBoxList"); +} diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 40498422..c9789f86 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -46,10 +46,58 @@ pub struct Textures { /// Canvas renders so far (tests, performance checks). pub canvas_renders: u64, thumbs: HashMap, + /// SmartArt gallery pictures by layout, style and colours (and, for hover previews, the + /// outline's hash). + smartart: HashMap, clock: u64, pub last_render_ms: f64, } +impl Textures { + /// SmartArt gallery pictures cached so far (tests). + pub fn smartart_cached(&self) -> usize { + self.smartart.len() + } + + /// A SmartArt layout drawn small for a gallery (`nodes` for a preview of the selected + /// graphic's own outline), cached; drawn with the active presentation's theme. + pub fn smartart( + &mut self, + ctx: &egui::Context, + p: &deckcraft_model::Presentation, + slide: usize, + kind: &str, + style: &str, + colors: &str, + nodes: Option<&[deckcraft_model::DiagramNode]>, + width_px: u32, + ) -> Option { + let mut key = format!("{kind}|{style}|{colors}|{width_px}"); + if let Some(n) = nodes { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for x in n { + (x.text.as_str(), x.level).hash(&mut h); + } + key.push_str(&format!("|{:x}", h.finish())); + } + if let Some(t) = self.smartart.get(&key) { + return Some(t.clone()); + } + let mut s = p.slides.get(slide).or_else(|| p.slides.first())?.as_ref().clone(); + let g = deckcraft_engine::cmd::smartart::preview(kind, style, colors, nodes); + let id = g.id; + s.shapes = vec![g]; + let img = deckcraft_render::render_shape(p, &s, id, width_px as f64 / 336.0)?; + let tex = ctx.load_texture(format!("smartart-{key}"), to_color_image(&img, false), TextureOptions::LINEAR); + if self.smartart.len() > 300 { + self.smartart.clear(); + } + self.smartart.insert(key, tex.clone()); + Some(tex) + } +} + pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { let w = img.width as usize; let h = img.height as usize; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1f26fea1..7f7d0ecd 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -117,6 +117,9 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele let arrow_w = if split { 12.0 } else { 0.0 }; let w = 24.0 + tw + arrow_w; let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), if enabled { Sense::click() } else { Sense::hover() }); + if !label.is_empty() { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + } let painter = ui.painter_at(rect); let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y)); let main_resp = From b6aa6ab4e4c547190e8cc9de69c8d7b53139d17a Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:41:01 +0300 Subject: [PATCH 13/17] UI: Date and Time dialog inserts a format in one step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert › Date & Time opens the format list instead of placing a date immediately. With no format, update flag or language, insert.dateTime opens the dialog and does not edit. OK applies one undo: a field when Update automatically is on, or the shown text when it is off. Co-authored-by: Cursor --- ROADMAP.md | 2 + crates/engine/src/cmd/insert.rs | 340 ++++++++++++++++++++++++++- crates/ui-egui/src/datetime_tests.rs | 78 ++++++ crates/ui-egui/src/dialogs.rs | 93 ++++++++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/ribbon.rs | 7 +- docs/architecture.md | 5 +- docs/gaps.md | 7 +- docs/target-app-parity.md | 4 +- docs/ui-parity.md | 7 +- 10 files changed, 533 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/datetime_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..77d095c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Insert › Date and Time: the ribbon and Insert menu open a format list (datetime1–datetime13) with a language and Update automatically. OK inserts one field, or the shown text when Update is off. 28 dialogs; 226 commands. English has every format; en-GB, French, Japanese and Swedish list the numeric date and 24-hour time | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Insert › Date and Time dialog (28 dialogs, 226 commands; breadth and readiness unchanged) | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..438878ef 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -47,7 +47,15 @@ pub fn specs() -> Vec { cmd!("insert.video", "Video from File…", ["Insert", "Media", "Video"], None, "{path? | data: base64, name?, rect?}", has_slide, video), cmd!("insert.wordArt", "WordArt", ["Insert", "Text"], None, "{text?, style?: 0..}", has_slide, word_art), cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number), - cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time), + cmd!( + "insert.dateTime", + "Date & Time", + ["Insert", "Text"], + None, + "{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog", + has_slide, + date_time + ), cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol), cmd!( "insert.hyperlink", @@ -562,10 +570,198 @@ fn field_run(s: &mut Session, field: &str, text: &str) -> Result { fn slide_number(s: &mut Session, _p: &Value) -> Result { field_run(s, "slidenum", "‹#›") } + +const DATE_FORMATS: [&str; 13] = [ + "datetime1", + "datetime2", + "datetime3", + "datetime4", + "datetime5", + "datetime6", + "datetime7", + "datetime8", + "datetime9", + "datetime10", + "datetime11", + "datetime12", + "datetime13", +]; + +struct DateOpts { + format: String, + update: bool, + /// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language. + lang: Option, + sample: String, +} + +/// `true` when the call carries a format, update flag or language (apply it). Empty params open +/// the dialog instead. +fn date_time_apply(p: &Value) -> bool { + p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language")) +} + +fn canon_lang(raw: &str) -> Option<&'static str> { + match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() { + "en" | "en-us" => Some("en-US"), + "en-gb" => Some("en-GB"), + "fr" | "fr-fr" => Some("fr-FR"), + "ja" | "ja-jp" => Some("ja-JP"), + "sv" | "sv-se" => Some("sv-SE"), + _ => None, + } +} + +fn date_opts(p: &Value) -> Result { + if p.get("format").is_some() { + let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f)); + if f.is_none() { + return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13")); + } + } + if p.get("update").is_some() && bool_param(p, "update").is_none() { + return Err(bad("insert.dateTime", "`update` must be true or false")); + } + let lang = if p.get("language").is_some() { + let raw = str_param(p, "language").filter(|l| !l.trim().is_empty()); + let Some(raw) = raw else { + return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE")); + }; + Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string()) + } else { + None + }; + let format = str_param(p, "format").unwrap_or("datetime1").to_string(); + let sample = date_sample(&format, lang.as_deref())?; + Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample }) +} + +/// The text `format` shows now in `lang`. Errors when that language has no names for the format +/// (month names and AM/PM are English only). An empty string where there is no clock (wasm). +fn date_sample(format: &str, lang: Option<&str>) -> Result { + use deckcraft_text::datetime::{DateTime, field_text}; + let probe = DateTime::new(2026, 10, 9, 20, 5, 7); + if field_text(format, lang, probe).is_none() { + return Err(bad("insert.dateTime", "this format isn't available in that language")); + } + Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default()) +} + fn date_time(s: &mut Session, p: &Value) -> Result { - let f = str_param(p, "format").unwrap_or("datetime1").to_string(); - // Saved with the date it shows now (the text kept where the date can't be computed). - field_run(s, &f, &super::design::date_field_text(&f)) + if !date_time_apply(p) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) }); + return ok(); + } + let opts = date_opts(p)?; + let (doc, sel) = { + let st = s.doc()?; + (st.doc.clone(), st.selection.clone()) + }; + let r = apply_date(s, &opts); + if r.is_err() + && let Some(st) = s.active_mut() + { + st.doc = doc; + st.selection = sel; + } + r +} + +fn apply_date(s: &mut Session, o: &DateOpts) -> Result { + if s.doc()?.selection.text.is_some() { + if o.update { + super::text::insert_field(s, &o.format, &o.sample)?; + if let Some(lang) = &o.lang { + stamp_caret_lang(s, lang)?; + } + } else { + insert_plain(s, &o.sample, o.lang.as_deref())?; + } + return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample})); + } + // Not editing: the slide's date placeholder (this slide only). A layout with no date + // placeholder gets a text box instead, so the chosen format is still inserted. + s.execute("design.headerFooter", &json!({"date": true, "all": false}))?; + if !write_date_placeholder(s, o)? { + return insert_date_box(s, o); + } + Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample})) +} + +/// Point the current slide's date placeholder at this format (a field, or the sample as plain text). +fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result { + let format = o.format.clone(); + let sample = o.sample.clone(); + let lang = o.lang.clone(); + let update = o.update; + s.edit(|doc, sel| { + let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) }; + let sl = std::sync::Arc::make_mut(sl); + let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else { + return Ok(false); + }; + let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); + if let Some(lang) = lang { + props.lang = Some(lang); + } + let kind = if update { RunKind::Field { field: format } } else { RunKind::Text }; + let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() }; + let body = sh.text.clone().unwrap_or_default(); + sh.text = Some(TextBody { paragraphs: vec![para], ..body }); + Ok(true) + }) +} + +fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result { + let size = s.doc()?.doc.slide_size; + let y = (size.height - 48.0).max(0.0); + s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?; + if o.update { + super::text::insert_field(s, &o.format, &o.sample)?; + } else { + super::text::insert_text(s, &o.sample, None)?; + } + if let Some(lang) = &o.lang { + stamp_caret_lang(s, lang)?; + } + Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample})) +} + +/// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge, +/// so the date stays its own run next to the surrounding text. +fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> { + let text = text.to_string(); + let lang = lang.map(str::to_string); + super::text::edit_text(s, move |body, t| { + let (a, b) = t.ordered(); + let at = deckcraft_model::edit::delete(body, a, b); + let mut props = deckcraft_model::edit::props_at(body, at); + if let Some(lang) = lang.clone() { + props.lang = Some(lang); + } + let c = deckcraft_model::edit::insert(body, at, &text, Some(props)); + t.anchor = c; + t.caret = c; + })?; + Ok(()) +} + +/// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates. +fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> { + let lang = lang.to_string(); + super::text::edit_text(s, move |body, t| { + let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return }; + let mut at = 0usize; + for r in &mut p.runs { + let n = r.char_len(); + if n > 0 && at < t.caret.1 && t.caret.1 <= at + n { + r.props.lang = Some(lang); + return; + } + at += n; + } + })?; + Ok(()) } fn symbol(s: &mut Session, p: &Value) -> Result { @@ -798,3 +994,139 @@ fn smart_art(s: &mut Session, p: &Value) -> Result { } Ok(g) } + +#[cfg(test)] +mod tests { + use deckcraft_model::text::RunKind; + use serde_json::json; + + use crate::Session; + + fn session() -> Session { + Session::with_new() + } + + fn undo(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + + fn date_run(s: &Session) -> Option { + s.doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) + .and_then(|x| x.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .cloned() + } + + fn title_field(s: &Session) -> deckcraft_model::Run { + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + title.paragraphs[0].runs.iter().find(|r| r.text.contains(':') || matches!(r.kind, RunKind::Field { .. })).cloned().unwrap() + } + + fn edit_title(s: &mut Session) { + let id = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "On "})).unwrap(); + } + + #[test] + fn no_params_opens_the_dialog_and_edits_nothing() { + let mut s = session(); + let rev = s.doc().unwrap().revision; + for p in [json!({}), serde_json::Value::Null] { + s.ui_requests.clear(); + s.execute("insert.dateTime", &p).unwrap(); + assert!(matches!(&s.ui_requests[..], [crate::UiRequest::Dialog { id, .. }] if id == "dateTime")); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(undo(&s), 0); + assert!(date_run(&s).is_none()); + } + } + + #[test] + fn a_format_at_the_caret_is_one_updating_field() { + let mut s = session(); + edit_title(&mut s); + let before = undo(&s); + let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap(); + assert_eq!(v["where"], "caret"); + let run = title_field(&s); + assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{run:?}"); + assert!(run.text.contains(':'), "{run:?}"); + assert_eq!(undo(&s), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Date & Time"); + s.execute("edit.undo", &json!({})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().text(); + assert_eq!(title, "On "); + } + + #[test] + fn update_false_inserts_plain_text_in_the_chosen_language() { + let mut s = session(); + edit_title(&mut s); + let before = undo(&s); + let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); + let expect = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now).unwrap(); + s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + let run = title.paragraphs[0].runs.iter().find(|r| r.text == expect).cloned().unwrap(); + assert_eq!(run.kind, RunKind::Text); + assert_eq!(run.props.lang.as_deref(), Some("en-GB")); + assert_eq!(undo(&s), before + 1); + } + + #[test] + fn a_named_format_in_french_and_a_bad_value_change_nothing() { + let mut s = session(); + edit_title(&mut s); + let rev = s.doc().unwrap().revision; + let undos = undo(&s); + let err = s.execute("insert.dateTime", &json!({"format": "datetime2", "language": "fr-FR"})).unwrap_err(); + assert!(err.to_string().contains("isn't available"), "{err}"); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(undo(&s), undos); + for p in [ + json!({"format": "datetime14"}), + json!({"format": "nope"}), + json!({"update": "yes"}), + json!({"language": "de-DE"}), + json!({"language": ""}), + ] { + let err = s.execute("insert.dateTime", &p).unwrap_err(); + assert!(err.to_string().contains("insert.dateTime"), "{err}"); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(undo(&s), undos); + } + } + + #[test] + fn without_a_caret_the_date_lands_on_this_slide_only() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); + let clock = deckcraft_text::datetime::field_text("datetime10", Some("en-US"), now).unwrap(); + let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "language": "en_US"})).unwrap(); + assert_eq!(v["where"], "footer"); + let run = date_run(&s).unwrap(); + assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{run:?}"); + assert_eq!(run.text, clock); + assert_eq!(run.props.lang.as_deref(), Some("en-US")); + s.execute("slide.go", &json!({"index": 1})).unwrap(); + assert!(date_run(&s).is_none(), "the other slide keeps its own footer"); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let fixed = deckcraft_text::datetime::field_text("datetime12", None, now).unwrap(); + s.execute("insert.dateTime", &json!({"format": "datetime12", "update": false})).unwrap(); + let run = date_run(&s).unwrap(); + assert_eq!((run.kind, run.text), (RunKind::Text, fixed)); + s.execute("edit.undo", &json!({})).unwrap(); + let run = date_run(&s).unwrap(); + assert!(matches!(run.kind, RunKind::Field { .. }), "one undo restores the field"); + } +} diff --git a/crates/ui-egui/src/datetime_tests.rs b/crates/ui-egui/src/datetime_tests.rs new file mode 100644 index 00000000..c955aa2e --- /dev/null +++ b/crates/ui-egui/src/datetime_tests.rs @@ -0,0 +1,78 @@ +//! Insert › Date and Time, driven like a person: the ribbon opens the dialog, a format is +//! chosen, OK inserts it in one undo, Cancel leaves the slide alone. + +use deckcraft_engine::Session; +use deckcraft_model::text::RunKind; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.ui.tab = "Insert".into(); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn date_run(h: &Harness<'_, SlideApp>) -> Option { + h.state() + .session + .active() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) + .and_then(|x| x.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .cloned() +} + +#[test] +fn insert_opens_the_dialog_and_ok_inserts_one_field() { + let mut h = harness(); + h.get_by_label("Date & Time").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("dateTime")); + let _ = h.get_by_label("Update automatically"); + let _ = h.get_by_label("English (United States)"); + let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); + let sample = deckcraft_text::datetime::field_text("datetime2", Some("en-US"), now).unwrap(); + h.get_by_label(&sample).click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let run = date_run(&h).unwrap(); + assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime2"), "{run:?}"); + assert_eq!(run.text, sample); + let undo = &h.state().session.active().unwrap().history.undo; + assert_eq!(undo.len(), 1); + assert_eq!(undo[0].label, "Date & Time"); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + h.run_steps(1); + assert!(date_run(&h).is_none()); +} + +#[test] +fn cancel_closes_the_dialog_without_a_date() { + let mut h = harness(); + h.get_by_label("Date & Time").click(); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(date_run(&h).is_none()); + assert!(h.state().session.active().unwrap().history.undo.is_empty()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..8c15df91 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -48,6 +48,7 @@ fn title(id: &str) -> &'static str { "export" => "Export", "symbol" => "Symbol", "equation" => "Equation", + "dateTime" => "Date and Time", "spelling" => "Spelling", "accessibility" => "Accessibility Checker", "language" => "Language", @@ -730,6 +731,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, _) = buttons(ui, "Done"); ok } + "dateTime" => date_time_dialog(app, ui, d), _ => { ui.label(format!("“{}” is not available yet.", d.id)); let (ok, cancel) = buttons(ui, "OK"); @@ -738,6 +740,97 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } +const DATE_LANGS: &[(&str, &str)] = &[ + ("en-US", "English (United States)"), + ("en-GB", "English (United Kingdom)"), + ("fr-FR", "French (France)"), + ("ja-JP", "Japanese"), + ("sv-SE", "Swedish"), +]; + +const DATE_KINDS: [&str; 13] = [ + "datetime1", + "datetime2", + "datetime3", + "datetime4", + "datetime5", + "datetime6", + "datetime7", + "datetime8", + "datetime9", + "datetime10", + "datetime11", + "datetime12", + "datetime13", +]; + +/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only; +/// other languages keep the numeric date and the 24-hour times. +fn date_rows(lang: &str) -> Vec<(&'static str, String)> { + let now = deckcraft_text::datetime::DateTime::now_local(); + let mut rows = Vec::new(); + for kind in DATE_KINDS { + if let Some(now) = now + && let Some(sample) = deckcraft_text::datetime::field_text(kind, Some(lang), now) + { + rows.push((kind, sample)); + } + } + if rows.is_empty() { + rows.push(("datetime1", "datetime1".into())); + } + rows +} + +fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(440.0); + ui.label("Available formats:"); + let lang = d.get("language", "en-US"); + let rows = date_rows(&lang); + if !rows.iter().any(|(k, _)| *k == d.get("format", "datetime1")) + && let Some((k, _)) = rows.first() + { + d.fields.insert("format".into(), (*k).to_string()); + } + let format = d.get("format", "datetime1"); + egui::ScrollArea::vertical().max_height(280.0).min_scrolled_height(220.0).auto_shrink([false, false]).show(ui, |ui| { + for (kind, sample) in &rows { + if ui.selectable_label(format == *kind, sample).clicked() { + d.fields.insert("format".into(), (*kind).to_string()); + } + } + }); + let lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)"); + ui.horizontal(|ui| { + ui.label("Language:"); + let opened = egui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| { + for (k, l) in DATE_LANGS { + if ui.selectable_label(lang == *k, *l).clicked() { + d.fields.insert("language".into(), (*k).to_string()); + } + } + }); + opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, lang_label)); + }); + ui.label( + egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.") + .size(11.0) + .color(Tokens::get(ui.ctx()).text_dim), + ); + let update = check(ui, d, "Update automatically", "update", true); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let lang = d.get("language", "en-US"); + let mut format = d.get("format", "datetime1"); + let probe = deckcraft_text::datetime::DateTime::new(2026, 10, 9, 20, 5, 7); + if deckcraft_text::datetime::field_text(&format, Some(&lang), probe).is_none() { + format = "datetime1".into(); + } + return app.run("insert.dateTime", json!({"format": format, "update": update, "language": lang})).is_ok(); + } + cancel +} + fn pretty_equation(s: &str) -> String { let sup = |c: char| match c { '0' => '⁰', diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..ca47471b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1130,6 +1130,10 @@ fn js_now() -> f64 { #[cfg(test)] mod ime_tests; +#[cfg(test)] +#[path = "datetime_tests.rs"] +mod datetime_tests; + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..ba82ef70 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -229,7 +229,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); - if big_button(ui, icon, label, en).on_hover_text(tip(id, label)).clicked() { + let resp = big_button(ui, icon, label, en); + if id == "insert.dateTime" { + let name = label.replace('\n', " "); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, &name)); + } + if resp.on_hover_text(tip(id, label)).clicked() { run(app, id, p); } } diff --git a/docs/architecture.md b/docs/architecture.md index 2849f870..dae0b179 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; 28 dialogs, 226 commands; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what @@ -21,7 +21,7 @@ nothing below L6 uses egui, eframe, winit or rfd. | L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 | | L4 | `pdf` | PDF export through krilla | | L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | -| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | +| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with @@ -78,5 +78,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Insert › Date and Time dialog (`insert.dateTime` with no keys). 28 dialogs; command count stays 226 | | 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..9bb1caa8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Date and Time dialog; 28 dialogs; previous: 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -203,8 +203,8 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. +- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, + Replace Fonts, Action Settings, Print, Insert Object…). Date and Time is in. 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Insert › Date and Time dialog. Dialog count 27 → 28; Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print and Insert Object are still missing | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..d0777f60 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -219,7 +219,8 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc text readable. - **Text:** in-place editing with caret, selection, keyboard and mouse; every Home-tab font and paragraph control; bullets and numbering; levels; autofit; columns; vertical text; date fields in - their own format; hyperlinks drawn in the theme's hyperlink colour and followed in the show. + their own format (Insert › Date and Time picks the format and language, or inserts the sample as + fixed text); hyperlinks drawn in the theme's hyperlink colour and followed in the show. UAX #9 bidi and Arabic shaping with HarfRust, script font slots with per-cluster fallback, `justLow` kashida, bidi caret and selection. - **Editing:** select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; @@ -276,6 +277,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Insert › Date and Time noted under what works today. The d556ff9 snapshot (226 commands, 27 dialogs) and the readiness percentages are unchanged | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages | | 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..35c40b19 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,8 +1,8 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in @@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | Design Ideas (Designer) | yes (cloud) | no | -| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Date and Time, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Insert › Date and Time: format list, language, Update automatically. 28 dialogs. Languages are the ones the date field can compute (English, plus numeric dates for en-GB, fr-FR, ja-JP, sv-SE) | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | From 8c0147cfa7a9d74e3a77bf411fcefc5475a1841d Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 05:40:06 +0300 Subject: [PATCH 14/17] UI: Hyperlink dialog follows the selection Insert Hyperlink opened on https:// with an empty ScreenTip, so OK replaced a web address, email, slide link or tip. The dialog now starts from the link it would edit, and leaves actions it cannot edit in place. --- ROADMAP.md | 4 +- crates/engine/src/cmd/insert.rs | 4 +- crates/engine/src/cmd/text.rs | 5 +- crates/engine/src/tests.rs | 16 ++ crates/ui-egui/src/dialogs.rs | 348 ++++++++++++++++++++++++++++++-- docs/ui-parity.md | 5 +- 6 files changed, 363 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..0a76a4e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Hyperlink dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Insert Hyperlink: address, email, slide and ScreenTip open from the selection, so OK no longer replaces them with `https://` and an empty tip | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Hyperlink dialog follows the selection | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..75b85fed 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Link", ["Insert", "Links"], Some("Cmd+K"), - "{url? | slide?: index, tooltip?, text?: shown when nothing is selected, ids?}", + "{url? | slide?: index, tooltip?, text?: shown when nothing is selected, highlightClick?, ids?}", has_text_or_shapes, hyperlink ), @@ -589,7 +589,7 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result { return Err(bad("insert.hyperlink", "missing `url` or `slide`")); }; let display = str_param(p, "text").map_or(display, str::to_string); - let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false }; + let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: bool_or(p, "highlightClick", false) }; if s.doc()?.selection.text.is_some() { return super::text::link_text(s, link, &display); } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1b2cb2d2..05274b7e 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -790,8 +790,9 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy } /// What `format_run` formats while editing: the selection, or for a bare caret the word it's in -/// (without its trailing space). Empty when the caret is at a word boundary. -fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) { +/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink +/// edits this same span, so the dialog reads it too. +pub fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) { let body = body_of(st, t).cloned().unwrap_or_default(); let (mut a, mut b) = t.ordered(); if a == b { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..48dca837 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -504,6 +504,22 @@ fn hyperlink_on_a_word_or_shape_links_it() { assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action"); } +#[test] +fn hyperlink_can_keep_highlight_click() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("insert.hyperlink", &json!({"url": "https://example.org", "tooltip": "tip", "highlightClick": true})).unwrap(); + let click = s.doc().unwrap().current_slide().unwrap().shapes[0].click.clone().unwrap(); + assert_eq!(click.tooltip, "tip"); + assert!(click.highlight_click); + assert_eq!(click.action, model::text::Action::Url { url: "https://example.org".into() }); + s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); + let click = s.doc().unwrap().current_slide().unwrap().shapes[0].click.clone().unwrap(); + assert!(!click.highlight_click, "omitting the flag still stores false"); +} + /// A .pptx (base64) whose first slide's title is set in `font`. fn deck_in_font(font: &str) -> String { let mut p = deckcraft_model::Presentation::default(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..2deadf5b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -117,6 +117,186 @@ fn num(d: &mut Dialog, key: &str, default: f64) -> f64 { d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::().ok().filter(|v| v.is_finite()).unwrap_or(default) } +/// What Insert Hyperlink would edit on the current selection. +#[derive(Clone, Debug, PartialEq)] +enum CurrentLink { + /// Nothing is linked. OK sets a new link (a caret at a word boundary inserts text). + None, + /// One link covers the text span or every selected shape. + One(deckcraft_model::text::Hyperlink), + /// The selection mixes different links. OK must not replace them until a new target is chosen. + Mixed, +} + +/// Fields the Insert Hyperlink dialog opens with. +#[derive(Clone, Debug, PartialEq)] +struct HyperlinkSeed { + /// `url`, `email` or `slide`. + mode: &'static str, + url: String, + email: String, + slide: usize, + tip: String, + highlight: bool, + /// The action is not a web address, email or slide this dialog edits (or the selection mixes + /// links). OK leaves it in place until the user picks a different target. + preserve: bool, +} + +impl HyperlinkSeed { + /// A new link: the address starts at `https://`, as the dialog always did. + fn blank() -> Self { + HyperlinkSeed { mode: "url", url: "https://".into(), email: String::new(), slide: 0, tip: String::new(), highlight: false, preserve: false } + } +} + +/// Turn one hyperlink into dialog fields. A slide that is no longer in the deck, and actions this +/// dialog does not edit (next slide, a program, …), are preserved so OK does not retarget them. +fn seed_of_link(link: &deckcraft_model::text::Hyperlink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed { + use deckcraft_model::text::Action; + let mut seed = HyperlinkSeed { + mode: "url", + url: String::new(), + email: String::new(), + slide: 0, + tip: link.tooltip.clone(), + highlight: link.highlight_click, + preserve: true, + }; + match &link.action { + Action::Url { url } => { + seed.preserve = false; + if let Some(email) = url.strip_prefix("mailto:") { + seed.mode = "email"; + seed.email = email.to_string(); + seed.url = "https://".into(); + } else { + seed.url = url.clone(); + } + } + Action::Slide { slide } => { + if let Some(i) = slide_ids.iter().position(|id| id == slide) { + seed.preserve = false; + seed.mode = "slide"; + seed.slide = i; + seed.url = "https://".into(); + } + } + _ => {} + } + seed +} + +fn seed_from(current: CurrentLink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed { + match current { + CurrentLink::None => HyperlinkSeed::blank(), + CurrentLink::Mixed => HyperlinkSeed { preserve: true, url: String::new(), ..HyperlinkSeed::blank() }, + CurrentLink::One(link) => seed_of_link(&link, slide_ids), + } +} + +/// Links on the half-open span `[a, b)`. A point span is a new link: Insert Hyperlink inserts +/// there instead of editing a neighbouring run. +fn link_covering(body: &deckcraft_model::text::TextBody, a: (usize, usize), b: (usize, usize)) -> CurrentLink { + if a == b { + return CurrentLink::None; + } + let mut seen: Option> = None; + for (pi, para) in body.paragraphs.iter().enumerate() { + if pi < a.0 || pi > b.0 { + continue; + } + let from = if pi == a.0 { a.1 } else { 0 }; + let to = if pi == b.0 { b.1 } else { usize::MAX }; + if from >= to { + continue; + } + let mut acc = 0usize; + let mut covered = false; + for run in ¶.runs { + let len = run.char_len(); + let end = acc + len; + if len > 0 && end > from && acc < to { + covered = true; + match &seen { + None => seen = Some(run.props.link.clone()), + Some(prev) if prev == &run.props.link => {} + Some(_) => return CurrentLink::Mixed, + } + } + acc = end; + } + if !covered && para.char_len() == 0 { + match &seen { + None => seen = Some(para.end_props.link.clone()), + Some(prev) if prev == ¶.end_props.link => {} + Some(_) => return CurrentLink::Mixed, + } + } + } + match seen { + Some(Some(link)) => CurrentLink::One(link), + _ => CurrentLink::None, + } +} + +fn common_click(clicks: impl IntoIterator>) -> CurrentLink { + let mut clicks = clicks.into_iter(); + let Some(first) = clicks.next() else { return CurrentLink::None }; + for next in clicks { + if next != first { + return CurrentLink::Mixed; + } + } + match first { + Some(link) => CurrentLink::One(link), + None => CurrentLink::None, + } +} + +/// The link Insert Hyperlink would edit: text when a caret or selection is active, otherwise the +/// click action shared by the selected shapes. +fn hyperlink_seed_of(st: Option<&deckcraft_engine::DocState>) -> HyperlinkSeed { + let Some(st) = st else { return HyperlinkSeed::blank() }; + let ids: Vec = st.doc.slides.iter().map(|s| s.id).collect(); + let current = if let Some(t) = &st.selection.text { + match deckcraft_engine::cmd::text::body_of(st, t) { + Some(body) => { + let (a, b) = deckcraft_engine::cmd::text::format_range(st, t); + link_covering(body, a, b) + } + None => CurrentLink::None, + } + } else { + common_click(st.selected_shapes().into_iter().map(|sh| sh.click.clone())) + }; + seed_from(current, &ids) +} + +/// OK writes a link unless the dialog is holding an action it does not edit and the user has not +/// chosen a new target. +fn hyperlink_should_apply(seed: &HyperlinkSeed, mode: &str, url: &str, email: &str, slide: usize) -> bool { + if !seed.preserve { + return true; + } + if mode != seed.mode { + return true; + } + match mode { + "email" => email != seed.email, + "slide" => slide != seed.slide, + _ => url != seed.url, + } +} + +fn hyperlink_command(mode: &str, url: &str, email: &str, slide: usize, tip: &str, highlight: bool) -> Value { + match mode { + "slide" => json!({"slide": slide, "tooltip": tip, "highlightClick": highlight}), + "email" => json!({"url": format!("mailto:{email}"), "tooltip": tip, "highlightClick": highlight}), + _ => json!({"url": url, "tooltip": tip, "highlightClick": highlight}), + } +} + /// Returns true when the dialog should close. fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let run = |app: &mut SlideApp, id: &str, p: Value| { @@ -186,7 +366,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { apply || apply_all || d.fields.contains_key("cancel") } "hyperlink" => { - let mode = d.get("mode", "url"); + let seed = hyperlink_seed_of(app.session.active()); + let slide_default = seed.slide.to_string(); + let mode = d.get("mode", seed.mode); ui.horizontal(|ui| { if ui.selectable_label(mode == "url", "Web Page or File").clicked() { d.fields.insert("mode".into(), "url".into()); @@ -205,7 +387,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { .active() .map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect()) .unwrap_or_default(); - let sel = d.get("slide", "0").parse::().unwrap_or(0); + let sel = d.get("slide", &slide_default).parse::().unwrap_or(0); egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { for (i, t) in titles.iter().enumerate() { if ui.selectable_label(sel == i, t).clicked() { @@ -214,19 +396,20 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); } - "email" => field(ui, d, "Email address:", "email", ""), - _ => field(ui, d, "Address:", "url", "https://"), + "email" => field(ui, d, "Email address:", "email", &seed.email), + _ => field(ui, d, "Address:", "url", &seed.url), } - field(ui, d, "ScreenTip:", "tip", ""); + field(ui, d, "ScreenTip:", "tip", &seed.tip); let (ok, cancel) = buttons(ui, "OK"); if ok { - let tip = d.get("tip", ""); - let p = match mode.as_str() { - "slide" => json!({"slide": d.get("slide", "0").parse::().unwrap_or(0), "tooltip": tip}), - "email" => json!({"url": format!("mailto:{}", d.get("email", "")), "tooltip": tip}), - _ => json!({"url": d.get("url", ""), "tooltip": tip}), - }; - run(app, "insert.hyperlink", p); + let mode_now = d.get("mode", seed.mode); + let url = d.get("url", &seed.url); + let email = d.get("email", &seed.email); + let slide = d.get("slide", &slide_default).parse::().unwrap_or(0); + let tip = d.get("tip", &seed.tip); + if hyperlink_should_apply(&seed, &mode_now, &url, &email, slide) { + run(app, "insert.hyperlink", hyperlink_command(&mode_now, &url, &email, slide, &tip, seed.highlight)); + } } ok || cancel } @@ -829,3 +1012,144 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +#[cfg(test)] +mod tests { + use deckcraft_model::text::{Action, Hyperlink, Run, TextBody}; + use deckcraft_model::{Presentation, SlideId}; + + use super::{ + CurrentLink, HyperlinkSeed, common_click, hyperlink_command, hyperlink_seed_of, hyperlink_should_apply, link_covering, seed_from, + seed_of_link, + }; + + fn link(action: Action, tip: &str) -> Hyperlink { + Hyperlink { action, tooltip: tip.into(), highlight_click: false } + } + + fn url(address: &str, tip: &str) -> Hyperlink { + let mut h = link(Action::Url { url: address.into() }, tip); + h.highlight_click = tip == "glow"; + h + } + + #[test] + fn no_link_opens_as_a_new_web_address() { + let seed = seed_from(CurrentLink::None, &[]); + assert_eq!(seed, HyperlinkSeed::blank()); + assert!(hyperlink_should_apply(&seed, "url", "https://", "", 0)); + } + + #[test] + fn web_email_and_slide_links_fill_the_dialog() { + let ids = [SlideId(7), SlideId(9)]; + let web = seed_of_link(&url("https://example.org", "See"), &ids); + assert_eq!(web.mode, "url"); + assert_eq!(web.url, "https://example.org"); + assert_eq!(web.tip, "See"); + assert!(!web.preserve); + assert!(hyperlink_should_apply(&web, "url", "https://example.org", "", 0)); + + let mail = seed_of_link(&url("mailto:a@b.org", "Write"), &ids); + assert_eq!(mail.mode, "email"); + assert_eq!(mail.email, "a@b.org"); + assert_eq!(mail.tip, "Write"); + assert_eq!(hyperlink_command("email", "", "a@b.org", 0, "Write", false)["url"], "mailto:a@b.org"); + + let slide = seed_of_link(&link(Action::Slide { slide: SlideId(9) }, "There"), &ids); + assert_eq!(slide.mode, "slide"); + assert_eq!(slide.slide, 1); + assert_eq!(slide.tip, "There"); + assert!(!slide.preserve); + + let glow = url("https://example.org", "glow"); + assert!(glow.highlight_click); + assert!(seed_of_link(&glow, &ids).highlight); + assert_eq!(hyperlink_command("url", "https://example.org", "", 0, "glow", true)["highlightClick"], true); + } + + #[test] + fn actions_this_dialog_does_not_edit_survive_ok() { + let next = seed_of_link(&link(Action::NextSlide, "On"), &[]); + assert!(next.preserve); + assert_eq!(next.tip, "On"); + assert_eq!(next.url, ""); + assert!(!hyperlink_should_apply(&next, "url", "", "", 0)); + assert!(hyperlink_should_apply(&next, "url", "https://example.org", "", 0)); + + let gone = seed_of_link(&link(Action::Slide { slide: SlideId(99) }, ""), &[SlideId(1)]); + assert!(gone.preserve, "a missing slide is not retargeted to the first slide"); + assert!(!hyperlink_should_apply(&gone, "url", "", "", 0)); + + let mixed = seed_from(CurrentLink::Mixed, &[]); + assert!(mixed.preserve); + assert!(!hyperlink_should_apply(&mixed, "url", "", "", 0)); + assert!(hyperlink_should_apply(&mixed, "email", "", "a@b.org", 0)); + } + + #[test] + fn a_text_span_reports_the_link_it_covers() { + let mut body = TextBody::from_text("Hello world"); + body.paragraphs[0].runs.clear(); + body.paragraphs[0].runs.push(Run::new("Hello ")); + let mut world = Run::new("world"); + world.props.link = Some(url("https://example.org", "tip")); + body.paragraphs[0].runs.push(world); + + assert_eq!(link_covering(&body, (0, 6), (0, 6)), CurrentLink::None, "a caret inserts; it does not edit the run beside it"); + match link_covering(&body, (0, 6), (0, 11)) { + CurrentLink::One(h) => assert_eq!(h.tooltip, "tip"), + other => panic!("linked word, got {other:?}"), + } + assert_eq!(link_covering(&body, (0, 0), (0, 11)), CurrentLink::Mixed); + + let mut other = url("https://example.org", "tip"); + other.tooltip = "other".into(); + assert_eq!(common_click([None, None]), CurrentLink::None); + assert!(matches!(common_click([Some(url("https://example.org", "tip"))]), CurrentLink::One(_))); + assert_eq!(common_click([Some(url("https://example.org", "tip")), Some(other)]), CurrentLink::Mixed); + assert_eq!(common_click(std::iter::empty()), CurrentLink::None); + } + + #[test] + fn the_open_dialog_reads_the_caret_word_and_the_shape_click() { + let mut doc = Presentation::default(); + let shape_id = { + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + let mut body = TextBody::from_text("Hello world"); + body.paragraphs[0].runs.clear(); + body.paragraphs[0].runs.push(Run::new("Hello ")); + let mut world = Run::new("world"); + world.props.link = Some(url("https://example.org", "tip")); + body.paragraphs[0].runs.push(world); + slide.shapes[0].text = Some(body); + slide.shapes[0].id + }; + let mut st = deckcraft_engine::DocState::new(doc, None, "t".into()); + st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 8), caret: (0, 8), ..Default::default() }); + let seed = hyperlink_seed_of(Some(&st)); + assert_eq!(seed.mode, "url"); + assert_eq!(seed.url, "https://example.org"); + assert_eq!(seed.tip, "tip"); + assert!(!seed.preserve); + + st.selection.text = None; + st.selection.shapes = vec![shape_id]; + { + let doc = std::sync::Arc::make_mut(&mut st.doc); + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + let shape = slide.shapes.iter_mut().find(|sh| sh.id == shape_id).unwrap(); + let mut click = url("https://shape.example", "shape tip"); + click.highlight_click = true; + shape.click = Some(click); + } + let seed = hyperlink_seed_of(Some(&st)); + assert_eq!(seed.url, "https://shape.example"); + assert_eq!(seed.tip, "shape tip"); + assert!(seed.highlight); + + st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 11), caret: (0, 11), ..Default::default() }); + let seed = hyperlink_seed_of(Some(&st)); + assert_eq!(seed, HyperlinkSeed::blank(), "a caret at the end of the word inserts a new link"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..7d4ac20b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Hyperlink dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | Design Ideas (Designer) | yes (cloud) | no | -| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink (address, slide and ScreenTip follow the selection), Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Hyperlink dialog: address, email, slide and ScreenTip follow the selection | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | From 4c1357b2e66b4a2351b25f6a7ead4d962ea73d91 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:29:52 -0400 Subject: [PATCH 15/17] Fix up merged PRs: shape.action fills main's new Hyperlink sound fields and CustomShow returns Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/insert.rs | 4 ++-- crates/ui-egui/src/dialogs.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 930b4356..64eff386 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -686,7 +686,7 @@ fn shape_action(s: &mut Session, p: &Value) -> Result { if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) { return Err(bad(CMD, format!("no custom show `{name}`"))); } - Some(Action::CustomShow { name }) + Some(Action::CustomShow { name, returns: true }) } "program" => Some(Action::Program { path: need("path")? }), "slide" => { @@ -698,7 +698,7 @@ fn shape_action(s: &mut Session, p: &Value) -> Result { }; let highlight = bool_param(p, "highlight").unwrap_or(true); let tooltip = str_param(p, "tooltip").unwrap_or("").to_string(); - let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight }); + let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false }); edit_shapes(s, p, CMD, |sh| { if hover { sh.hover = link.clone(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 60880b16..3ae2e03d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -486,7 +486,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { Some(Action::EndShow) => ("endShow", String::new()), Some(Action::PlayMedia) => ("playMedia", String::new()), Some(Action::Url { url }) => ("url", url.clone()), - Some(Action::CustomShow { name }) => ("customShow", name.clone()), + Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()), Some(Action::Program { path }) => ("program", path.clone()), Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), }; From 4653cb4e18e34fd4ba1f09424fa8769ea2059089 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:30:15 -0400 Subject: [PATCH 16/17] Fix up merged PRs: Hyperlink dialog tests fill main's new sound fields Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b2047412..63a85070 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1527,7 +1527,7 @@ mod tests { }; fn link(action: Action, tip: &str) -> Hyperlink { - Hyperlink { action, tooltip: tip.into(), highlight_click: false } + Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false } } fn url(address: &str, tip: &str) -> Hyperlink { From 0376c0b0e137111cb691d76402cdd8aa2f2671c3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:41:30 -0400 Subject: [PATCH 17/17] Fix up merged PRs: one parity summary, one dialogs gap entry, no duplicated architecture rows Recomputed docs/parity-checklist.md's summary with cargo xtask parity. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 -- docs/gaps.md | 8 ++------ docs/parity-checklist.md | 12 ++++-------- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b1a64f63..da57f5bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,8 +26,6 @@ nothing below L6 uses egui, eframe, winit or rfd. | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (228 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | -| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | -| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with diff --git a/docs/gaps.md b/docs/gaps.md index b820ac27..eee83d1b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -300,12 +300,8 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. -- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). Size and Position is one of the 28. 8–14 h. -- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). Date and Time is in. 8–14 h. +- **Dialogs**: 30 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, Replace Fonts, Print, + Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index eb6cf53b..10f21882 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -8,12 +8,8 @@ The summary between ` | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 39 | 20 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | +| P1 | 65 | 39 | 21 | 5 | 76% | +| P2 | 54 | 14 | 17 | 23 | 42% | | Area | Parity | |---|---| @@ -22,7 +18,7 @@ The summary between ` | Slides | 90% | | Views | 93% | | Masters & themes | 85% | -| Shapes | 88% | +| Shapes | 91% | | Format | 83% | | Arrange | 100% | | Text | 80% | @@ -31,7 +27,7 @@ The summary between ` | SmartArt | 50% | | Pictures | 48% | | Media | 67% | -| Transitions | 92% | +| Transitions | 100% | | Animations | 79% | | Slide show | 66% | | Review | 60% |