From e1cfe8516e444af9357d5ea135f9b067de0bc70e Mon Sep 17 00:00:00 2001 From: Stephan Schott-Verdugo Date: Sun, 1 Nov 2026 22:29:43 +0100 Subject: [PATCH] Paste and duplicate keep a placeholder's inherited look A placeholder copied or duplicated on a slide became a regular shape by clearing `ph`, which also dropped everything it inherited from its layout and master: the title's 44 pt heading font, the body's bullets and indent levels, autofit and the box. Without `ph` the text fell back to the presentation's default style (18 pt, no bullets), on Ctrl+V and the context menu alike. `resolve::detach_placeholder` makes the inherited box, fill, line, effects, body properties and every level's text style the shape's own before `ph` is cleared. Theme references stay references, so the default paste still follows the destination theme. Copy (so Cut and pasting into another deck work after the source is gone) and Duplicate use it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/engine/src/cmd/edit.rs | 15 ++++++- crates/engine/src/tests.rs | 85 +++++++++++++++++++++++++++++++++++ crates/model/src/resolve.rs | 39 ++++++++++++++++ 4 files changed, 140 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6255c9421..fd17b3a1c 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-14 · **Last updated:** 2026-11-01 · **Change:** major (fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.14.1; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** major (fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.14.1; pasted and duplicated placeholders keep their look; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-01 | Copy, Cut, Paste and Duplicate of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text | | 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f084c6225..9674bb8ad 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -120,6 +120,13 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { }) }) .collect(); + // A placeholder pastes on a slide as a regular shape; it keeps the look it inherited here. + let mut shapes = shapes; + if st.selection.target == crate::Target::Slides + && let Some(ctx) = st.current_slide().and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(&st.doc, sl)) + { + shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh)); + } let mids = crate::media_refs::shapes_media(&shapes); let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::>().join("\n"); @@ -340,7 +347,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { x.x += off; x.y += off; } - // A pasted placeholder becomes a regular shape keeping its look. + // A pasted placeholder becomes a regular shape; copying gave it its look. if sh.ph.is_some() && sel.target == crate::Target::Slides { sh.ph = None; } @@ -533,6 +540,12 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let sources: Vec = copies.iter().map(|c| c.id).collect(); let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); + // A duplicated placeholder becomes a regular shape that keeps the look it inherited. + if sel.target == crate::Target::Slides + && let Some(ctx) = doc.slides.get(sel.slide).and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(doc, sl)) + { + copies.iter_mut().for_each(|c| deckcraft_model::resolve::detach_placeholder(&ctx, c)); + } // Duplicating the copies of the last Duplicate again repeats how far they were moved // since (#966); anything else gets the usual offset. let (dx, dy) = last diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a687de95c..eec43cc4a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -214,6 +214,91 @@ fn clipboard_copy_paste_shapes_and_slides() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font, +/// colour, bold, bullet, indent, alignment, and the box's insets and anchor. +fn resolved_look(s: &Session, id: ShapeId) -> String { + use deckcraft_model::resolve; + let st = s.doc().unwrap(); + let slide = st.current_slide().unwrap(); + let ctx = resolve::Ctx::for_slide(&st.doc, slide).unwrap(); + let sh = st.shape(id).unwrap(); + let t = sh.text.as_ref().unwrap(); + let b = resolve::body(&ctx, sh); + let mut out = format!("body {:?} {:?} {:?}\n", b.anchor, b.inset_l, b.autofit); + for p in &t.paragraphs { + let pp = resolve::para(&ctx, sh, p); + out += &format!("para {:?} {:?} {:?} {:?}\n", pp.align, pp.margin_left, pp.indent, pp.bullet); + for r in &p.runs { + let rp = resolve::run(&ctx, sh, p, &r.props); + out += &format!(" run {:?} {:?} {:?} {:?}\n", rp.size, rp.font, rp.bold, rp.fill); + } + } + out +} + +/// A placeholder pasted or duplicated on a slide becomes a regular shape, and keeps the look its +/// layout and master gave it: the title's size and font, the body's bullets and indents. Before, +/// the copy fell back to the master's other-text style (18 pt, no bullets). +#[test] +fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let phs: Vec = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect(); + assert!(phs.len() >= 2, "a title and a body placeholder"); + for (k, id) in phs.iter().enumerate() { + s.execute("text.set", &json!({"id": id.0, "text": format!("Placeholder {k}\nSecond line")})).unwrap(); + } + for id in phs { + let want = resolved_look(&s, id); + for cmd in ["paste", "duplicate"] { + s.execute("selection.select", &json!({"id": id.0})).unwrap(); + if cmd == "paste" { + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + } else { + s.execute("edit.duplicate", &json!({})).unwrap(); + } + let copy = s.doc().unwrap().selection.shapes[0]; + assert_ne!(copy, id); + assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "{cmd}: the copy is a regular shape"); + assert_eq!(resolved_look(&s, copy), want, "{cmd} of placeholder {}", id.0); + } + } + assert!(s.doc().unwrap().doc.validate().is_empty()); +} + +/// The same on every text placeholder of the sample deck (titles, bulleted bodies, subtitles). +#[test] +fn sample_placeholders_paste_with_their_look() { + let mut s = Session::new(); + crate::sample::open_sample(&mut s).unwrap(); + let slides = s.doc().unwrap().doc.slides.len(); + let mut checked = 0; + for i in 0..slides { + s.execute("slide.go", &json!({"index": i})).unwrap(); + let phs: Vec = s + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .filter(|x| x.ph.is_some() && x.text.as_ref().is_some_and(|t| !t.is_empty())) + .map(|x| x.id) + .collect(); + for id in phs { + let want = resolved_look(&s, id); + s.execute("selection.select", &json!({"id": id.0})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert_eq!(resolved_look(&s, copy), want, "slide {i}, placeholder {}", id.0); + checked += 1; + } + } + assert!(checked >= 3, "the sample has text placeholders ({checked})"); +} + #[test] fn pasted_shapes_get_the_next_automatic_name() { let mut s = session(); diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 8d87229f3..8cf87582d 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -218,6 +218,45 @@ pub fn effects(ctx: &Ctx, shape: &Shape) -> (Option, Option) { (None, None) } +/// Turn a placeholder into a regular shape that looks the same: what it inherited from its layout +/// and master placeholders (position, fill, line, effects, style, body properties and the text +/// style of every outline level, the master's title or body style included) becomes its own, and +/// `ph` is cleared. A shape that is not a placeholder is left as it is. +/// +/// Theme references (`+mj-lt`, scheme colours) stay references, so the copy still follows the +/// destination theme, as PowerPoint's default paste does. +pub fn detach_placeholder(ctx: &Ctx, shape: &mut Shape) { + if shape.ph.is_none() { + return; + } + let (lp, mp) = parents(ctx, shape); + let parents = [lp, mp].into_iter().flatten(); + shape.xfrm = Some(xfrm(ctx, shape)); + if shape.fill.is_none() && shape.style.is_none() { + shape.fill = parents.clone().find_map(|s| s.fill.clone()); + } + if shape.style.is_none() { + shape.style = parents.clone().find_map(|s| s.style.clone()); + } + if shape.effects.is_none() { + shape.effects = parents.clone().find_map(|s| s.effects.clone()); + } + let mut line = shape.line.clone(); + for p in parents.filter_map(|s| s.line.as_ref()) { + line.get_or_insert_with(Line::default).inherit(p); + } + shape.line = line; + if shape.text.is_some() { + let body = body(ctx, shape); + let levels: Vec> = (0..9).map(|l| Some(level_style(ctx, shape, l))).collect(); + if let Some(t) = shape.text.as_mut() { + t.body = body; + t.list_style = ListStyle { levels }; + } + } + shape.ph = None; +} + /// Body properties with defaults filled in. pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { let (lp, mp) = parents(ctx, shape);