diff --git a/Cargo.lock b/Cargo.lock index 2c3ea457..9e71a6e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -957,6 +957,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index f746cf0e..a9aa7e75 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,6 +25,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors; breadth and ready percentages unchanged; previous: hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Fonts; breadth and ready percentages unchanged; previous: hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Alt Text decorative checkbox; breadth stays 79%; previous: minor, hours to ~95%) · **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 @@ -184,6 +185,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | Design › Colors › Customize Colors… opens Create New Theme Colors: the open master's scheme name and twelve theme colours, applied in one undo. Command count stays 226; dialogs 27 → 28 | | 2026-10-14 | Design › Fonts › Customize Fonts… opens Create New Theme Fonts: the open master's scheme name and its heading and body latin faces, applied in one undo. Command count stays 226; dialogs 27 → 28 | | 2026-10-14 | Alt Text: Mark as decorative opens checked when the selection already is, so OK no longer clears it | +| 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-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 | @@ -234,6 +237,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | trivial | Progress log: Create New Theme Colors | | 2026-10-14 | trivial | Progress log: Create New Theme Fonts | | 2026-10-14 | trivial | Progress log: Alt Text decorative checkbox | +| 2026-10-14 | trivial | Progress log: Hyperlink dialog follows the selection | +| 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-13 | trivial | Progress log: slide show for interactive decks | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 9493ddba..778664a7 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -28,7 +28,9 @@ image = { workspace = true } [dev-dependencies] deckcraft-anim = { workspace = true } +zip = { workspace = true } # The browser clock for comment timestamps (wasm has no std::time::SystemTime). [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" + diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 3c16c7b2..20084231 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -75,14 +75,31 @@ 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!( + "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", ["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 ), @@ -110,7 +127,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 ), @@ -1347,10 +1364,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 { @@ -1374,8 +1579,13 @@ 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, sound: None, stop_sound: false }; + let link = Hyperlink { + action, + tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), + highlight_click: bool_or(p, "highlightClick", false), + sound: None, + stop_sound: false, + }; if s.doc()?.selection.text.is_some() { return super::text::link_text(s, link, &display); } @@ -1385,6 +1595,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, returns: true }) + } + "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, sound: None, stop_sound: false }); + 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"); @@ -1495,98 +1754,155 @@ 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) } #[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"); + } +} + +#[cfg(test)] +mod icon_and_picture_tests { use deckcraft_model::ShapeKind; use deckcraft_model::style::LineJoin; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b38f65fd..9ccc3fce 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,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; @@ -134,6 +135,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(background::specs()); v.extend(picture_style::specs()); diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs new file mode 100644 index 00000000..03276e9f --- /dev/null +++ b/crates/engine/src/cmd/smartart.rs @@ -0,0 +1,2163 @@ +//! 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::style::{Gradient, GradientShape, GradientStop, Shadow}; +use deckcraft_model::text::{Align, AutoFit, Paragraph, TextBody}; +use deckcraft_model::{ + ColorRef, ColorTransform, Diagram, DiagramNode, Effects, Fill, Geom, Line, 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; + +/// DeckCraft's layouts, with the names PowerPoint's gallery gives them. +pub const LAYOUTS: [(&str, &str); 45] = [ + ("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"), + ("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"), + ("textCycle", "Text Cycle"), + ("continuousCycle", "Continuous Cycle"), + ("horizontalHierarchy", "Horizontal Hierarchy"), + ("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"), + ("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] = [ + ("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); 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"), +]; + +/// 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", + } +} + +/// 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) +} + +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?, title?} (index + text edits one node; title: its Name and Title box)", + has_selection, + set_nodes + ), + cmd!( + "smartArt.addNode", + "Add Shape", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index?, position?: after|before|below|above|assistant, 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|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", + ["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|polished|inset|cartoon|powder|brick}", + has_selection, + change_style + ), + cmd!( + "smartArt.colors", + "Change Colors", + ["SmartArt Design", "SmartArt Styles"], + None, + "{id?, colors: colorful|range23|range34|range45|range56|accent1|…|accent6|accent1Grad|…|accent6Grad}", + has_selection, + change_colors + ), + ] +} + +/// 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), + picture: None, + assistant: false, + title: String::new(), + } + }) + .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, picture: None, assistant: false, title: String::new() }); + 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) + } + "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", title: String::new() }); + 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 +} + +/// 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() { + Some(g) if n.level > 0 => g.push(i), + _ => out.push(vec![i]), + } + } + out.truncate(MAX_SHAPES); + out +} + +/// Read text typed into the diagram's shapes back into its nodes. +fn sync_text(d: &mut Diagram, children: &[Shape]) { + if d.imported { + return; + } + 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 idx.is_empty() || 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] +} + +/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN` +/// keeps one. +fn accent(d: &Diagram, i: usize) -> SchemeSlot { + 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, 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") +} + +/// 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 = 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 || matches!(d.style.as_str(), "outline" | "powder"); + 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() }); + } + "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, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })), + ..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| 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); + 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); + 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 head { 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("")); + } + 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 { + 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"), + 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 { + 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; 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 }; + // 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 = 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); + 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); + 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, false), + "horizontalHierarchy" => return hierarchy(d, area, true), + "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"; + 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 * 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); + } + } + } + "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); + } + } + "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 }], + }; + // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. + // 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())); + } + } + "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); + } + 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())); + } + } + "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); + } + } + } + "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())); + } + } + "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; + } + } + } + "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); + 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; + 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.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)); + 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 +} + +/// 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) -> 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![]; + } + 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 { + // Two half rings round the text: the upper left one in the accent, the lower right one + // in its tint. + let x = boxes[i]; + 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 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 +} + +/// 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)]; + 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); + // 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; + 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 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 + .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]); + 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 +} + +/// 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); + 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"))?; + 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 is_kind(kind) { kind } else { "process" }; + if nodes.is_empty() { + 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() }; + 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); + let title = str_param(p, "title").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; + } + 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) + }) +} + +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")) }) +} + +/// 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"))?; + // 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"))?; + 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); + 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); + 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 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()?; + 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)?; + 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(); + 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| 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. + d.layout_id.clear(); + d.layout_xml = None; + } + 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, picture: None, assistant: false, title: String::new() }) + .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: "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()); + 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]), ..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(), ..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 std::iter::once("").chain(STYLES.iter().map(|(k, _)| *k)) { + 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(); + // 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(); + 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:?}"); + } + } + } + } + + #[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()); + } + + #[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/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 76ae5210..84f58869 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -864,8 +864,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 691c39a1..2baeb70e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -586,6 +586,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(); @@ -1913,3 +1929,201 @@ fn table_borders_margins_and_cell_alignment() { s.execute("edit.undo", &json!({})).unwrap(); assert!(table(&s).rows[0].cells[0].anchor.is_none()); } + +#[test] +fn smartart_text_pane_edits_regenerate_the_diagram() { + let mut s = session(); + 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(), + _ => 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))); +} + +#[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(); + 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 = { + 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_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)]); + 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))); +} + +#[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()); + 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(_))))); + + // 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)); + } +} + +#[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); + // 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(); + 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"); +} + +/// 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/model/src/lib.rs b/crates/model/src/lib.rs index 6fd005c1..abff8813 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -327,6 +327,57 @@ 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 { + /// 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")] + 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). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +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, + /// 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 { @@ -352,6 +403,7 @@ impl Default for Shape { click: None, hover: None, raw_ext: None, + diagram: None, } } } diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs new file mode 100644 index 00000000..5f85db00 --- /dev/null +++ b/crates/pptx/src/dgm.rs @@ -0,0 +1,882 @@ +//! 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_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", + "polished" => "3d1", + "inset" => "3d2", + "cartoon" => "3d3", + "powder" => "3d4", + "brick" => "3d5", + _ => "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", + "3d1" => "polished", + "3d2" => "inset", + "3d3" => "cartoon", + "3d4" => "powder", + "3d5" => "brick", + "simple2" | "simple3" => "subtle", + _ => "intense", + } +} + +/// PowerPoint's colour definition for Change Colors. +pub fn cs_id(colors: &str) -> String { + 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(""); + 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(), + } +} +/// 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", + "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/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", + "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", + "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", + "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", + _ => "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 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", + "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", + "nameTitleOrgChart", + "verticalBoxList", + "trapezoidList", + "tableList", + "accentProcess", + "continuousBlockProcess", + "bendingProcess", + "linearVenn", + "stackedVenn", + "nestedTarget", + "cycleMatrix", + "segmentedPyramid", + "pie", + "list", + ] { + if layout_id == layout_id_of(k) { + return k; + } + } + let s = format!("{last} {}", category.to_ascii_lowercase()); + if s.contains("gear") { + "gear" + } else if s.contains("funnel") { + "funnel" + } else if s.contains("venn") { + "venn" + } else if s.contains("target") { + "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") { + "hierarchy" + } 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("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" + | "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" | "nameTitleOrgChart" => "hierarchy", + "pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid", + "matrix" | "titledMatrix" | "gridMatrix" => "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; `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)); + 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(&d.style)) + .a("qsCatId", "simple") + .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"); + 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() { + 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); + 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"); + } + } + // 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; + } + 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![]; + 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 { + 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 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 }; + match pt.attr("type").unwrap_or("node") { + "doc" => doc = doc.or(Some(id)), + ty @ ("node" | "asst") => { + let t = pt.child("t").map(paragraphs).unwrap_or_default(); + texts.insert(id, t); + if ty == "asst" { + 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); + } + } + _ => {} + } + } + 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(( + id.to_string(), + 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)))); + } + } + 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" | "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" + | "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" | "segmentedPyramid" => { + ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") + } + "matrix" | "titledMatrix" | "gridMatrix" | "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"), + } +} + +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 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", "")); + 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 !matches!(kind, "cycle" | "blockCycle" | "radial") { + space(&mut w); + } + w.close("dgm:forEach"); + } + w.close("dgm:layoutNode"); + w.close("dgm:layoutDef"); + 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 { + // 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", "")); + w.empty("dgm:desc", A::new().a("val", "")); + w.open0("dgm:catLst"); + 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", 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", camera)); + w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); + w.close("dgm:scene3d"); + 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")] { + 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(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(colors))); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + 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", cat).a("pri", "11200")); + w.close("dgm:catLst"); + w.open("dgm:styleLbl", A::new().a("name", "node1")); + if colors.ends_with("Grad") { + // Gradient Range: the accent shaded darker across the shapes. + w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); + 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 a in &accents { + w.empty("a:schemeClr", A::new().a("val", a.as_str())); + } + 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"); + 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, picture: None, assistant: false, title: String::new() }).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("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 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\"")); + 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() }; + 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"] { + 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")); + assert!(layout_id("pictureProcess").ends_with("/hProcess10"), "Picture Accent Process"); + assert!(layout_id("pictureHList").ends_with("/pList2"), "Horizontal Picture List"); + // 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", + "segmentedCycle", + "multidirectionalCycle", + "orgChart", + "halfCircleOrgChart", + "cycleMatrix", + "segmentedPyramid", + "pie", + "nameTitleOrgChart", + "verticalBoxList", + "trapezoidList", + "tableList", + "accentProcess", + "continuousBlockProcess", + "bendingProcess", + "linearVenn", + "stackedVenn", + "nestedTarget", + ] { + assert_eq!(kind_for(layout_id(k), ""), k); + } + } + + #[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", ""), "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"); + 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/lib.rs b/crates/pptx/src/lib.rs index 2cfc0f30..770963bd 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -9,6 +9,7 @@ mod cfb; 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 b59828d0..ba163897 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -508,6 +508,18 @@ 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); + // 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) } @@ -617,6 +629,50 @@ fn text_area(geom: &Geom, w: f64, h: f64) -> deckcraft_geom::Rect { } } +/// 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 }; + let pr = data.path(&["ptLst"]).and_then(|l| l.children_named("pt").find(|p| p.attr("type") == Some("doc"))).and_then(|p| p.child("prSet")); + let style = crate::dgm::style_for(pr.and_then(|p| p.attr("qsTypeId")).unwrap_or("")).to_string(); + let colors = crate::dgm::colors_for(pr.and_then(|p| p.attr("csTypeId")).unwrap_or("")); + Some(deckcraft_model::Diagram { + kind: crate::dgm::kind_for(&id, &cat).to_string(), + nodes, + style, + colors, + 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 27f532e8..43655864 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -52,6 +52,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, @@ -168,6 +169,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); @@ -467,6 +473,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> 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 8bc5d5fb..b4f22aab 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -205,7 +205,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); @@ -223,12 +227,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); } @@ -249,7 +257,7 @@ fn sp_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) { w.raw(&xml); } } - w.close("p:spPr"); + w.close(tag); } /// Write the children of a shape tree. @@ -302,6 +310,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"); @@ -464,6 +477,89 @@ fn write_model( w.close("mc:AlternateContent"); } +/// 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"); + // 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)); + 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(&d.style))); + 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(&d.colors))); + 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/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 f69d9dac..bea7f6df 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -52,6 +52,7 @@ fn title(id: &str) -> &'static str { "photoAlbum" => "Photo Album", "replaceFonts" => "Replace Font", "equation" => "Equation", + "dateTime" => "Date and Time", "spelling" => "Spelling", "pasteSpecial" => "Paste Special", "accessibility" => "Accessibility Checker", @@ -61,6 +62,7 @@ fn title(id: &str) -> &'static str { "trim" => "Trim Media", "smartart" => "Choose a SmartArt Graphic", "bullets" => "Bullets and Numbering", + "action" => "Action Settings", "quit" => "DeckCraft", "themeColors" => "Create New Theme Colors", "themeFonts" => "Create New Theme Fonts", @@ -685,6 +687,186 @@ fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { cancel } +/// 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| { @@ -766,7 +948,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()); @@ -785,7 +969,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() { @@ -794,19 +978,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 } @@ -1025,6 +1210,127 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel || none } + "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, deco_now) = app .session @@ -1440,6 +1746,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 } @@ -1490,6 +1797,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "photoAlbum" => photo_album_dialog(app, ui, d), "themeColors" => theme_colors_dialog(app, ui, d), "themeFonts" => theme_fonts_dialog(app, ui, d), + "dateTime" => date_time_dialog(app, ui, d), _ => { ui.label(format!("“{}” is not available yet.", d.id)); let (ok, cancel) = buttons(ui, "OK"); @@ -1737,6 +2045,97 @@ fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool cancel } +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' => '⁰', @@ -1846,7 +2245,7 @@ mod theme_colors_tests; mod theme_fonts_tests; #[cfg(test)] -mod tests { +mod alt_text_tests { use super::alt_text_starts_decorative; #[test] @@ -1858,3 +2257,144 @@ mod tests { assert!(!alt_text_starts_decorative([true, false])); } } + +#[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, sound: None, stop_sound: 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/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 09787e2c..ae207d1d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -89,7 +89,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, @@ -104,6 +105,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, /// Comment the Review Previous/Next buttons last landed on: `(slide, index)`. #[serde(skip_serializing_if = "Option::is_none")] @@ -132,6 +139,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, comment_focus: None, } @@ -164,7 +174,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}"), @@ -187,6 +197,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, "{}"), @@ -596,6 +607,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"), @@ -667,14 +684,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()); @@ -1244,6 +1272,10 @@ mod slide_size_tests; #[cfg(test)] mod ui_soak_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/panes.rs b/crates/ui-egui/src/panes.rs index ed9a88f9..6225753a 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -28,6 +28,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => "Selection", "comments" => "Comments", "designer" => "Design Ideas", + "smartart" => "Text Pane", _ => "Pane", }; ui.horizontal(|ui| { @@ -51,11 +52,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); } @@ -993,3 +1084,7 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec } }); } + +#[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 4df41c95..78787241 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -125,6 +125,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"); } @@ -223,6 +226,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), _ => {} @@ -232,7 +236,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); } } @@ -568,6 +577,83 @@ 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. +/// 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(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.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) { let en = enabled(app, "format.bullets"); group(ui, |ui| { @@ -678,6 +764,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, "smartArt.fromText", json!({}), "", "", "", None)); }); }); }); @@ -1115,6 +1203,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| { @@ -2807,6 +2900,96 @@ 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, 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| { + 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"), + ("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})); + } + } + }); + 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})); + } + }); + 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); + let outline = sel.as_ref().map(|(_, d)| d.nodes.clone()); + egui::Popup::menu(&r).show(|ui| { + 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| { + 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); + 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})); + } + } + }); + 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})); + } + } + }); + }); + 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..0e668439 --- /dev/null +++ b/crates/ui-egui/src/smartart_tests.rs @@ -0,0 +1,111 @@ +//! 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)]); +} + +#[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_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 02c56925..e60aafbe 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 20792f32..47fc53a4 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 = @@ -159,6 +162,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) } diff --git a/docs/architecture.md b/docs/architecture.md index 46a4641c..c7167b06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,6 +6,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Edit Photo Album, `insert.photoAlbumEdit`; 228 commands, 28 dialogs; previous: trivial, Photo Album dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors dialog; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Fonts dialog; previous: transition frames) · **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 @@ -97,5 +98,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | 2026-10-14 | trivial | Edit Photo Album (`insert.photoAlbumEdit`) reopens the Photo Album dialog and re-flows those slides (228 commands, 28 dialogs) | | 2026-10-14 | minor | Create New Theme Colors (`design.colors` with no params); command count stays 226 | | 2026-10-14 | minor | Create New Theme Fonts (`design.fonts` with no params); command count stays 226 | +| 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/file-format-parity.md b/docs/file-format-parity.md index 64a53c70..305cbacd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -92,6 +92,8 @@ the round trip even though DeckCraft doesn't model it. | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | | SmartArt (`dgm`) | drawing part only, as a group; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) | +| 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 aa73f47f..9962ca63 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -18,6 +18,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Edit Photo Album reuses the Photo Album dialog, still 28; Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain; previous: trivial, Photo Album counted) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors; dialogs 27 → 28; previous: 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Fonts; dialogs 27 → 28; previous: 3-D bevel, camera and face lighting) · **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 @@ -216,11 +217,19 @@ 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. -- **Impact:** editing a SmartArt diagram from PowerPoint breaks it. -- **Estimate:** 10–16 h. +### SmartArt editing is shallow +- **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. 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. +- **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**. @@ -314,6 +323,8 @@ Detail in [ui-parity.md](ui-parity.md). Settings, Print, Insert Object. 8–14 h. - **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, Action Settings, Print, Insert Object…). 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 @@ -392,6 +403,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-14 | trivial | Edit Photo Album reuses the Photo Album dialog (still 28 vs ~60). Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain | | 2026-10-14 | minor | Create New Theme Colors is in (dialogs 27 → 28). Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print and Insert Object stay open | | 2026-10-14 | minor | Create New Theme Fonts is in (dialogs 27 → 28): heading and body latin on the open master. Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print and Insert Object stay open | +| 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-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f416c788..6448d41c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -14,6 +14,8 @@ The summary between ` | P2 | 54 | 13 | 16 | 25 | 39% | | P1 | 65 | 38 | 21 | 6 | 75% | | P2 | 54 | 14 | 15 | 25 | 40% | +| P1 | 65 | 39 | 21 | 5 | 76% | +| P2 | 54 | 14 | 17 | 23 | 42% | | Area | Parity | |---|---| @@ -25,6 +27,7 @@ The summary between ` | Views | 90% | | Masters & themes | 88% | | Shapes | 88% | +| Shapes | 91% | | Format | 83% | | Arrange | 100% | | Text | 80% | @@ -32,8 +35,10 @@ The summary between ` | Charts | 65% | | SmartArt | 33% | | Pictures | 52% | +| SmartArt | 50% | +| Pictures | 48% | | Media | 67% | -| Transitions | 92% | +| Transitions | 100% | | Animations | 79% | | Slide show | 66% | | Review | 60% | @@ -183,7 +188,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 a94d5ff6..4c47250c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -198,7 +198,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, 3-D models and stock content, 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) | @@ -235,7 +235,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; @@ -304,6 +305,7 @@ scored done (16 open issues). | 2026-10-14 | trivial | Working today: 228 commands. Breadth stays 79%, ready ~55%. The d556ff9 snapshot (226 commands, 27 dialogs) is unchanged | | 2026-10-14 | minor | What works today: Create New Theme Colors. The d556ff9 snapshot (226 commands, 27 dialogs) and the breadth / ready percentages are unchanged | | 2026-10-14 | minor | What works today: Create New Theme Fonts. The d556ff9 snapshot (226 commands, 27 dialogs) and the breadth / ready percentages are unchanged | +| 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 c8cf535b..ee8ab95c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -13,11 +13,14 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Fonts; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Alt Text dialog's decorative checkbox follows the selection; previous: File tab / Backstage, #76) · **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-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-14 · **Change:** minor (File › New starter decks; previous: 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 (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Fonts; previous: text editing keys, #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 @@ -88,6 +91,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | 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, 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, Create New Theme Fonts | | 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, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Replace Fonts, 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, Set Up Show, Zoom, Comment, Alt Text (decorative follows the selection), 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 | +| 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 @@ -192,6 +197,8 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b | 2026-10-14 | minor | Design › Colors › Customize Colors… opens Create New Theme Colors (the open master's twelve theme colours; dialogs 27 → 28) | | 2026-10-14 | minor | Design › Fonts › Customize Fonts… opens Create New Theme Fonts (heading and body latin on the open master; dialogs 27 → 28) | | 2026-10-14 | trivial | Alt Text dialog: Mark as decorative follows the selection | +| 2026-10-14 | trivial | Hyperlink dialog: address, email, slide and ScreenTip follow the selection | +| 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 |