diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..6643c0dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Photo Album dialog; breadth stays 79%, ready ~55%; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Photo Album dialog (`insert.photoAlbum`): pictures and text boxes become slides in the open presentation, one undo step. Breadth stays 79%, ready ~55% | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Photo Album dialog. Breadth stays 79%, ready ~55% | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..2b5e605a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -33,6 +33,15 @@ pub fn specs() -> Vec { has_slide, picture ), + cmd!( + "insert.photoAlbum", + "Photo Album…", + ["Insert", "Images"], + None, + "{pictures?: [{path?|data: base64, name?} | {text}], layout?: fit|1|2|4|1title|2title|4title, frame?: none|rect|rounded|simple, captions?: bool} — omit pictures to open the dialog", + has_doc, + photo_album + ), cmd!("insert.table", "Table…", ["Insert", "Tables"], None, "{rows, cols, rect?}", has_slide, table), cmd!( "insert.chart", @@ -372,6 +381,336 @@ fn picture(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id})) } +const ALBUM_MAX_ITEMS: usize = 48; +const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024; +const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024; +const ALBUM_MAX_TEXT: usize = 400; + +#[derive(Clone, Copy)] +struct AlbumLayout { + cols: usize, + rows: usize, + per: usize, + title: bool, + fit: bool, +} + +#[derive(Clone, Copy)] +enum AlbumFrame { + None, + Rect, + Rounded, + Simple, +} + +struct AlbumPic { + name: String, + bytes: Vec, + px_w: f64, + px_h: f64, +} + +enum AlbumItem { + Picture(AlbumPic), + Text(String), +} + +enum AlbumSlide { + Pictures(Vec), + Text(String), +} + +/// Insert a photo album after the current slide. +/// +/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one +/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit. +fn photo_album(s: &mut Session, p: &Value) -> Result { + match p.get("pictures") { + None | Some(Value::Null) => { + s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) }); + return Ok(json!({"dialog": "photoAlbum"})); + } + Some(_) => {} + } + let layout = album_layout(p)?; + let frame = album_frame(p)?; + let captions = album_captions(p)?; + let items = album_items(p)?; + let (snapshot, sel) = { + let st = s.doc()?; + (st.doc.clone(), st.selection.clone()) + }; + let result = insert_album(s, items, layout, frame, captions); + if result.is_err() + && let Some(st) = s.active_mut() + { + st.doc = snapshot; + st.selection = sel; + } + result +} + +fn album_norm(s: &str) -> String { + s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() +} + +fn album_layout(p: &Value) -> Result { + let key = match p.get("layout") { + None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")), + }; + match album_norm(key).as_str() { + "fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }), + "1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), + "1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }), + "2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }), + "2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }), + "4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }), + "4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }), + _ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))), + } +} + +fn album_frame(p: &Value) -> Result { + let key = match p.get("frame") { + None => return Ok(AlbumFrame::None), + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")), + }; + match album_norm(key).as_str() { + "none" => Ok(AlbumFrame::None), + "rect" | "rectangle" => Ok(AlbumFrame::Rect), + "rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded), + "simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple), + _ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))), + } +} + +fn album_captions(p: &Value) -> Result { + match p.get("captions") { + None => Ok(false), + Some(Value::Bool(v)) => Ok(*v), + Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")), + } +} + +fn album_items(p: &Value) -> Result> { + let Some(arr) = p.get("pictures").and_then(Value::as_array) else { + return Err(bad("insert.photoAlbum", "`pictures` must be a list")); + }; + if arr.is_empty() { + return Err(bad("insert.photoAlbum", "add at least one picture or text box")); + } + if arr.len() > ALBUM_MAX_ITEMS { + return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes")); + } + let mut items = Vec::with_capacity(arr.len()); + let mut total = 0usize; + for item in arr { + let Some(obj) = item.as_object() else { + return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box")); + }; + let has_media = obj.contains_key("data") || obj.contains_key("path"); + if !has_media { + let Some(text) = obj.get("text").and_then(Value::as_str) else { + return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`")); + }; + let text = text.trim(); + if text.is_empty() { + return Err(bad("insert.photoAlbum", "a text box needs some text")); + } + if text.chars().count() > ALBUM_MAX_TEXT { + return Err(bad("insert.photoAlbum", "text is longer than 400 characters")); + } + items.push(AlbumItem::Text(text.to_string())); + continue; + } + let (name, bytes) = media_bytes(item, "insert.photoAlbum")?; + if bytes.len() > ALBUM_MAX_BYTES { + return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB"))); + } + let next = total.saturating_add(bytes.len()); + if next > ALBUM_MAX_TOTAL { + return Err(bad("insert.photoAlbum", "the album is larger than 64 MB")); + } + total = next; + let ct = content_type(&name, &bytes); + if !ct.starts_with("image/") { + return Err(bad("insert.photoAlbum", format!("{name} is not a picture"))); + } + if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { + return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read"))); + } + let (px_w, px_h) = image_px(&bytes); + items.push(AlbumItem::Picture(AlbumPic { name, bytes, px_w, px_h })); + } + Ok(items) +} + +fn image_px(bytes: &[u8]) -> (f64, f64) { + image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .map(|(w, h)| (w as f64, h as f64)) + .filter(|(w, h)| *w > 0.0 && *h > 0.0) + .unwrap_or((4.0, 3.0)) +} + +fn file_stem(name: &str) -> String { + let base = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name); + let stem = match base.rsplit_once('.') { + Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s, + _ => base, + }; + if stem.is_empty() { "Picture".to_string() } else { stem.to_string() } +} + +fn album_plan(items: Vec, per: usize) -> Vec { + let per = per.max(1); + let mut out = Vec::new(); + let mut batch = Vec::new(); + for item in items { + match item { + AlbumItem::Text(text) => { + if !batch.is_empty() { + out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); + } + out.push(AlbumSlide::Text(text)); + } + AlbumItem::Picture(pic) => { + batch.push(pic); + if batch.len() >= per { + out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); + } + } + } + } + if !batch.is_empty() { + out.push(AlbumSlide::Pictures(batch)); + } + out +} + +struct Placed { + pic: [f64; 4], + caption: Option<[f64; 4]>, +} + +fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) { + let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 }; + let box_w = box_w.max(1.0); + let box_h = box_h.max(1.0); + if !aspect.is_finite() || aspect <= 0.0 { + return (box_w, box_h); + } + if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) } +} + +fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec) { + let margin = if layout.fit { 18.0 } else { 36.0 }; + let title_band = if layout.title { 48.0 } else { 0.0 }; + let gap = 12.0; + let cap_h = 22.0; + let cap_gap = 4.0; + let left = margin; + let top = margin + title_band; + let grid_w = (width - margin - left).max(1.0); + let grid_h = (height - margin - top).max(1.0); + let cols = layout.cols.max(1); + let rows = layout.rows.max(1); + let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0); + let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0); + let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h }; + let mut placed = Vec::with_capacity(n); + for i in 0..n { + let col = i % cols; + let row = i / cols; + if row >= rows { + break; + } + let cell_x = left + col as f64 * (cell_w + gap); + let cell_y = top + row as f64 * (cell_h + gap); + placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None }); + } + let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None }; + (title, placed) +} + +fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec) { + let (title, slots) = place_album(width, height, layout, pics.len(), captions); + let placed = pics + .iter() + .zip(slots) + .map(|(pic, slot)| { + let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h); + let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0; + let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0; + let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None }; + Placed { pic: [x, y, dw, dh], caption } + }) + .collect(); + (title, placed) +} + +fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> { + let params = match frame { + AlbumFrame::None => return Ok(()), + AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}), + AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}), + AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}), + }; + s.execute("shape.line", ¶ms)?; + Ok(()) +} + +fn insert_album(s: &mut Session, items: Vec, layout: AlbumLayout, frame: AlbumFrame, captions: bool) -> Result { + let (width, height, first) = { + let st = s.doc()?; + let first = if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) }; + (st.doc.slide_size.width, st.doc.slide_size.height, first) + }; + let plan = album_plan(items, layout.per); + let mut slides = 0usize; + let mut pictures = 0usize; + for slide in plan { + s.execute("slide.new", &json!({"layout": "blank"}))?; + slides += 1; + match slide { + AlbumSlide::Text(text) => { + let rect = if layout.title { + [36.0, 18.0, (width - 72.0).max(36.0), 48.0] + } else { + [36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)] + }; + s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?; + } + AlbumSlide::Pictures(pics) => { + let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default(); + let (title, placed) = place_pictures(width, height, layout, &pics, captions); + for (pic, cell) in pics.iter().zip(placed) { + let data = base64_encode(&pic.bytes); + let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?; + let Some(id) = inserted.get("id").and_then(Value::as_u64) else { + return Err(bad("insert.photoAlbum", "the picture was not inserted")); + }; + apply_frame(s, frame, id)?; + pictures += 1; + if let Some(cap) = cell.caption { + s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?; + } + } + if let Some(rect) = title { + s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?; + } + } + } + } + s.execute("slide.go", &json!({"index": first}))?; + Ok(json!({"slides": slides, "first": first, "pictures": pictures})) +} + fn table(s: &mut Session, p: &Value) -> Result { let rows = usize_param(p, "rows").unwrap_or(2).clamp(1, 75); let cols = usize_param(p, "cols").unwrap_or(2).clamp(1, 75); @@ -798,3 +1137,161 @@ fn smart_art(s: &mut Session, p: &Value) -> Result { } Ok(g) } + +#[cfg(test)] +mod tests { + use deckcraft_model::ShapeKind; + use deckcraft_model::style::LineJoin; + + use super::*; + + /// 8×8 red PNG. + fn png() -> Vec { + vec![ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, + 68, 65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, + 66, 96, 130, + ] + } + + fn pic(name: &str) -> Value { + json!({"name": name, "data": base64_encode(&png())}) + } + + fn n_slides(s: &Session) -> usize { + s.active().map(|d| d.doc.slides.len()).unwrap_or(0) + } + + fn pictures(s: &Session, index: usize) -> Vec { + s.active() + .and_then(|d| d.doc.slides.get(index)) + .map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect()) + .unwrap_or_default() + } + + fn box_text(s: &Session, index: usize) -> Vec { + s.active() + .and_then(|d| d.doc.slides.get(index)) + .map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect()) + .unwrap_or_default() + } + + #[test] + fn two_pictures_one_per_slide_are_one_undo() { + let mut s = Session::with_new(); + let before = n_slides(&s); + let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album"); + assert_eq!(r["slides"], 2); + assert_eq!(n_slides(&s), before + 2); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(pictures(&s, 2).len(), 1); + assert_eq!(s.active().unwrap().selection.slide, 1); + let undo = &s.active().unwrap().history.undo; + assert_eq!(undo.len(), 1); + assert_eq!(undo[0].label, "Photo Album…"); + s.execute("edit.undo", &json!({})).expect("undo"); + assert_eq!(n_slides(&s), before); + assert!(pictures(&s, 1).is_empty()); + } + + #[test] + fn two_up_shares_one_slide() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album"); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 2); + assert!(box_text(&s, 1).is_empty()); + } + + #[test] + fn captions_and_titles_use_the_file_stem() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions"); + assert_eq!(box_text(&s, 1), ["harbor"]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title"); + assert_eq!(box_text(&s, 1), ["harbor"]); + assert_eq!(pictures(&s, 1).len(), 1); + let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y; + assert!(pic_y > 40.0, "the picture sits below the title"); + } + + #[test] + fn frames_set_the_picture_line() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect"); + let line = pictures(&s, 1)[0].line.clone().expect("line"); + assert_eq!(line.width, Some(1.5)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded"); + let line = pictures(&s, 1)[0].line.clone().expect("line"); + assert_eq!(line.width, Some(2.25)); + assert_eq!(line.join, Some(LineJoin::Round)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple"); + assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0)); + } + + #[test] + fn a_text_box_breaks_the_picture_row() { + let mut s = Session::with_new(); + let r = s + .execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"})) + .expect("album"); + assert_eq!(r["slides"], 3); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(box_text(&s, 2), ["A day on the water"]); + assert!(pictures(&s, 2).is_empty()); + assert_eq!(pictures(&s, 3).len(), 1); + assert_eq!(s.active().unwrap().history.undo.len(), 1); + } + + #[test] + fn no_pictures_opens_the_dialog_without_editing() { + let mut s = Session::with_new(); + let before = n_slides(&s); + let r = s.execute("insert.photoAlbum", &json!({})).expect("open"); + assert_eq!(r["dialog"], "photoAlbum"); + assert_eq!(n_slides(&s), before); + assert!(s.active().unwrap().history.undo.is_empty()); + assert_eq!(s.ui_requests.len(), 1); + match &s.ui_requests[0] { + crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"), + other => panic!("dialog request, got {other:?}"), + } + } + + #[test] + fn bad_input_adds_no_slides() { + let mut s = Session::with_new(); + let before = n_slides(&s); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err()); + let mut many = Vec::new(); + for i in 0..49 { + many.push(json!({"text": format!("box {i}")})); + } + assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err()); + assert_eq!(n_slides(&s), before); + assert!(s.active().unwrap().history.undo.is_empty()); + } + + #[test] + fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap(); + let one = pictures(&s, 1)[0].xfrm.unwrap().h; + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap(); + let fitted = pictures(&s, 1)[0].xfrm.unwrap().h; + assert!(fitted > one, "fit {fitted} should be taller than one-up {one}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap(); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 4); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..eda8a436 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -47,6 +47,7 @@ fn title(id: &str) -> &'static str { "preferences" => "Preferences", "export" => "Export", "symbol" => "Symbol", + "photoAlbum" => "Photo Album", "equation" => "Equation", "spelling" => "Spelling", "accessibility" => "Accessibility Checker", @@ -117,6 +118,189 @@ fn num(d: &mut Dialog, key: &str, default: f64) -> f64 { d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::().ok().filter(|v| v.is_finite()).unwrap_or(default) } +fn album_items(d: &Dialog) -> Option> { + let raw = d.fields.get("items").map(String::as_str).unwrap_or("[]"); + serde_json::from_str::(raw).ok().and_then(|v| v.as_array().cloned()) +} + +fn store_album_items(d: &mut Dialog, items: &[Value]) { + let raw = serde_json::to_string(items).unwrap_or_else(|_| "[]".into()); + d.fields.insert("items".into(), raw); +} + +fn album_item_label(v: &Value) -> String { + if let Some(text) = v.get("text").and_then(Value::as_str) { + let text = text.trim(); + if text.is_empty() { "Text Box".to_string() } else { format!("Text: {text}") } + } else { + v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("Picture").to_string() + } +} + +fn pick_album_picture(app: &mut SlideApp) -> Option { + let path = if let Some(pick) = app.services.pick_open.as_mut() { + pick("picture") + } else { + app.set_status("Pass pictures to insert.photoAlbum"); + None + }; + let path = path?; + let loaded = if let Some(read) = app.services.read.as_ref() { + match read(&path) { + Ok(bytes) => bytes, + Err(e) => { + app.set_status(e); + return None; + } + } + } else { + #[cfg(not(target_arch = "wasm32"))] + { + match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) => { + app.set_status(format!("{path}: {e}")); + return None; + } + } + } + #[cfg(target_arch = "wasm32")] + { + app.set_status("The web build inserts album pictures from data passed to insert.photoAlbum"); + return None; + } + }; + if loaded.is_empty() { + app.set_status("That file is empty"); + return None; + } + if loaded.len() > 32 * 1024 * 1024 { + app.set_status("That picture is larger than 32 MB"); + return None; + } + let name = std::path::Path::new(&path).file_name().and_then(|n| n.to_str()).filter(|s| !s.is_empty()).unwrap_or("picture").to_string(); + Some(json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&loaded)})) +} + +fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, key: &str, default: &str, choices: &[(&str, &str)]) { + let cur = d.get(key, default); + let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default); + egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| { + for (k, l) in choices { + if ui.selectable_label(cur == *k, *l).clicked() { + d.fields.insert(key.into(), (*k).to_string()); + } + } + }); +} + +/// Photo Album. Returns true when the dialog should close. +fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(400.0); + ui.label("Pictures in album"); + let items = album_items(d).unwrap_or_default(); + let mut sel = d.get("sel", "0").parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(140.0).id_salt("album_list").show(ui, |ui| { + if items.is_empty() { + ui.label(egui::RichText::new("No pictures yet").color(Tokens::get(ui.ctx()).text_dim)); + } + for (i, item) in items.iter().enumerate() { + if ui.selectable_label(sel == i, album_item_label(item)).clicked() { + sel = i; + } + } + }); + d.fields.insert("sel".into(), sel.to_string()); + ui.horizontal(|ui| { + if ui.button("Add Pictures…").clicked() + && let Some(item) = pick_album_picture(app) + { + match album_items(d) { + Some(mut list) if list.len() < 48 => { + list.push(item); + let next = list.len().saturating_sub(1); + store_album_items(d, &list); + d.fields.insert("sel".into(), next.to_string()); + } + Some(_) => app.set_status("A photo album holds at most 48 pictures or text boxes"), + None => app.set_status("The picture list is invalid"), + } + } + if ui.button("New Text Box").clicked() { + let text = d.get("boxText", "Text Box"); + let text = text.trim(); + if text.is_empty() { + app.set_status("Type the text for the new text box"); + } else if text.chars().count() > 400 { + app.set_status("Text is longer than 400 characters"); + } else if let Some(mut list) = album_items(d) { + if list.len() >= 48 { + app.set_status("A photo album holds at most 48 pictures or text boxes"); + } else { + list.push(json!({"text": text})); + let next = list.len().saturating_sub(1); + store_album_items(d, &list); + d.fields.insert("sel".into(), next.to_string()); + } + } + } + if ui.button("Remove").clicked() + && let Some(mut list) = album_items(d) + && let Some(i) = d.get("sel", "0").parse::().ok() + && i < list.len() + { + list.remove(i); + store_album_items(d, &list); + let next = i.min(list.len().saturating_sub(1)); + d.fields.insert("sel".into(), next.to_string()); + } + }); + field(ui, d, "Text box:", "boxText", "Text Box"); + ui.add_space(4.0); + ui.label("Picture layout"); + album_choice( + ui, + d, + "album_layout", + "layout", + "1", + &[ + ("fit", "Fit to slide"), + ("1", "1 picture"), + ("2", "2 pictures"), + ("4", "4 pictures"), + ("1title", "1 picture with title"), + ("2title", "2 pictures with title"), + ("4title", "4 pictures with title"), + ], + ); + ui.label("Frame shape"); + album_choice( + ui, + d, + "album_frame", + "frame", + "rect", + &[("none", "None"), ("rect", "Rectangle"), ("rounded", "Rounded"), ("simple", "Simple, white")], + ); + let captions = check(ui, d, "Captions under pictures", "captions", false); + let (ok, cancel) = buttons(ui, "Create"); + if ok { + let Some(list) = album_items(d) else { + app.set_status("The picture list is invalid"); + return false; + }; + if list.is_empty() { + app.set_status("Add a picture or a text box first"); + return false; + } + let layout = d.get("layout", "1"); + let frame = d.get("frame", "rect"); + return app.run("insert.photoAlbum", json!({"pictures": list, "layout": layout, "frame": frame, "captions": captions})).is_ok(); + } + cancel +} + /// 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| { @@ -730,6 +914,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, _) = buttons(ui, "Done"); ok } + "photoAlbum" => photo_album_dialog(app, ui, d), _ => { ui.label(format!("“{}” is not available yet.", d.id)); let (ok, cancel) = buttons(ui, "OK"); @@ -829,3 +1014,7 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +#[cfg(test)] +#[path = "photo_album_tests.rs"] +mod photo_album_tests; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25398b45..9c4f7b4f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -215,6 +215,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Table…", "insert.table"), ("Chart", "insert.chart"), ("Picture from File…", "app.insertPictureDialog"), + ("Photo Album…", "insert.photoAlbum"), ("Audio from File…", "app.insertAudioDialog"), ("Video from File…", "app.insertVideoDialog"), ], diff --git a/crates/ui-egui/src/photo_album_tests.rs b/crates/ui-egui/src/photo_album_tests.rs new file mode 100644 index 00000000..fbd4e2c7 --- /dev/null +++ b/crates/ui-egui/src/photo_album_tests.rs @@ -0,0 +1,90 @@ +//! Photo Album dialog: the Insert ribbon opens it, and Create inserts the pictures in one step. + +use deckcraft_engine::Session; +use deckcraft_model::ShapeKind; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// 8×8 red PNG. +fn png() -> Vec { + vec![ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, 68, + 65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, + 130, + ] +} + +fn harness(app: SlideApp) -> Harness<'static, SlideApp> { + 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 +} + +fn slides(h: &Harness<'_, SlideApp>) -> usize { + h.state().session.active().map(|d| d.doc.slides.len()).unwrap_or(0) +} + +#[test] +fn insert_ribbon_opens_the_dialog_without_editing() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.ui.tab = "Insert".into(); + let mut h = harness(app); + h.get_by_label("Photo Album…").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("photoAlbum")); + assert_eq!(slides(&h), 1); + assert!(h.state().session.active().unwrap().history.undo.is_empty()); +} + +#[test] +fn create_inserts_the_album_in_one_step() { + let bytes = png(); + let services = Services { + pick_open: Some(Box::new(|_| Some("/tmp/cat.png".into()))), + read: Some(Box::new(move |_| Ok(bytes.clone()))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + app.run("insert.photoAlbum", json!({})).expect("open"); + let mut h = harness(app); + h.get_by_label("Add Pictures…").click(); + h.run_steps(2); + assert!(h.query_by_label("cat.png").is_some(), "the chosen file is listed"); + h.get_by_label("New Text Box").click(); + h.run_steps(2); + assert!(h.query_by_label("Text: Text Box").is_some()); + h.get_by_label("Create").click(); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Create closes the dialog"); + assert_eq!(slides(&h), 3); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.undo.len(), 1); + assert_eq!(st.history.undo[0].label, "Photo Album…"); + let pics: Vec<_> = st.doc.slides[1].shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect(); + assert_eq!(pics.len(), 1); + assert_eq!(pics[0].line.as_ref().and_then(|l| l.width), Some(1.5)); + let text: Vec<_> = st.doc.slides[2].shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect(); + assert_eq!(text, ["Text Box"]); +} + +#[test] +fn create_with_nothing_keeps_the_dialog() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.run("insert.photoAlbum", json!({})).expect("open"); + let mut h = harness(app); + h.get_by_label("Create").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("photoAlbum")); + assert_eq!(slides(&h), 1); + let status = h.state().status.as_ref().map(|s| s.0.as_str()).unwrap_or(""); + assert!(status.contains("picture") || status.contains("text"), "{status}"); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..ea2d0229 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -234,6 +234,16 @@ fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Va } } +/// Like [`big`], with an accesskit name (the painted label can be two lines). +fn big_labeled(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, access: &str, id: &str, p: Value) { + let en = enabled(app, id); + let resp = big_button(ui, icon, label, en).on_hover_text(tip(id, access)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, access)); + if resp.clicked() { + run(app, id, p); + } +} + fn small(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); if small_button(ui, icon, label, en, false).on_hover_text(tip(id, label)).clicked() { @@ -1031,6 +1041,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); + big_labeled(app, ui, Icon::Picture, "Photo\nAlbum", "Photo Album…", "insert.photoAlbum", json!({})); let r = big_button(ui, Icon::Screenshot, "Screenshot", false); let _ = r.on_hover_text("Screenshot (not available yet)"); }); diff --git a/docs/architecture.md b/docs/architecture.md index 2849f870..306a6ed0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Photo Album dialog, `insert.photoAlbum`; 227 commands, 28 dialogs; previous: minor, 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 @@ -20,8 +20,8 @@ nothing below L6 uses egui, eframe, winit or rfd. | L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock | | L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 | | L4 | `pdf` | PDF export through krilla | -| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | -| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (227 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | +| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with @@ -78,5 +78,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Photo Album dialog and `insert.photoAlbum` (227 commands, 28 dialogs) | | 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..bfacb0ab 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Photo Album is one of 28 dialogs; Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain; previous: minor, 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -203,8 +203,8 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. +- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Photo Album is one of them). Still missing Font, + Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Photo Album dialog counted (28 vs ~60). Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..5004423a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (automation 227 commands; breadth stays 79%, ready ~55%; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -239,7 +239,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline text, AutoRecover. -- **Automation:** 226 commands, every one reachable from `deckcraft-cli`, the JSON control channel +- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel and MCP. - **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09). @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Working today: 227 commands. Breadth stays 79%, ready ~55%. The d556ff9 snapshot (226 commands, 27 dialogs) is unchanged | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages | | 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..d3b62fd0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Photo Album dialog; 28 dialogs; breadth stays 79%, UI ~60%; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | Design Ideas (Designer) | yes (cloud) | no | -| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Photo Album, 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, Photo Album, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Photo Album dialog (28 dialogs). Breadth stays 79%, UI ~60% | | 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 |